<?php
/**
 * BWD Support Copilot widget.
 *
 * Self-contained, host-agnostic popup help chat for the WordPress admin.
 * Renders a floating help launcher on the host plugin's admin screens and
 * proxies chat/escalation to the Black and White Digital support hub
 * (BW Support AI /copilot endpoints). The widget is strictly read-only:
 * beyond its own consent + settings options it never modifies site state.
 *
 * This module is vendored verbatim into multiple plugin suites. It must
 * contain NO host-plugin-specific logic, class names, option names, text
 * domains or URLs — everything host-specific arrives via the config array
 * passed to the constructor. UI strings are plain English defaults that a
 * host may override (pre-translated) via the 'strings' config key.
 *
 * The wire contract and answer-tier behaviour are documented below the
 * direct-access guard, which must stay close to the top of the file.
 *
 * @package BlackWhiteDigital\SupportCopilot
 * @version 2.1.0
 */

namespace BlackWhiteDigital\SupportCopilot;

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

/*
 *
 * Loading (v1.3.0+, NEWEST WINS): hosts do not require this file directly.
 * Each vendored copy is loaded via its bundle entry (support-copilot.php),
 * which registers the copy with the frozen Loader in this namespace; the
 * highest registered version's copy of THIS file is required once and boots
 * every queued host config. The class_exists guard below remains only as
 * belt-and-braces against legacy direct requires.
 *
 * Wire contract (BW Support AI v1.8.x, support-copilot-spec-01):
 *   Auth on every call: token (private/tokened plugins) OR
 *   freemius_install_id (Freemius-licensed plugins; wins when > 0).
 *   POST {endpoint}/copilot          {slug, token|freemius_install_id, site,
 *                                     version, screen, suite, diagnostics,
 *                                     history, message}
 *     -> {reply, category_guess, escalation_offered, tier, remaining_allowance}
 *   POST {endpoint}/copilot-escalate {... as above ..., category, email,
 *                                     first_name, last_name}
 *     -> {ticket_id, message}
 *   POST {endpoint}/copilot-context  {auth fields, want:"manifest"}
 *     -> {files: [{id, name, size, set_label}]}
 *   POST {endpoint}/copilot-context  {auth fields, want:"files", ids:[...]}
 *     -> {files: [{id, content}]}
 *
 * Answer tiers (BYOK-first): when the host supplies a local_ai bridge and a
 * key is configured, the widget answers on the SITE'S OWN AI — it fetches the
 * version-matched knowledge from the hub (manifest -> local relevance pick ->
 * file fetch), builds the same scope-policy prompt locally, and labels the
 * reply "answered by your site's AI". Any failure on that path falls back
 * automatically to hub answering. Context calls share the hub's per-site rate
 * limit, so the manifest is transient-cached.
 *
 */

if ( ! class_exists( __NAMESPACE__ . '\\Copilot' ) ) :

	/**
	 * Config-driven support copilot widget.
	 */
	class Copilot {

		/**
		 * Module version (independent of any host plugin version).
		 */
		const MODULE_VERSION = '2.1.1';

		/**
		 * Max history turns sent per request (hub caps at 20).
		 */
		const MAX_HISTORY = 20;

		/**
		 * How long the hub knowledge manifest is cached locally (6 h). Context
		 * calls share the hub's per-site rate limit, so re-fetching the manifest
		 * on every turn would waste it.
		 */
		const MANIFEST_TTL = 21600;

		/**
		 * Max knowledge files fetched per locally-answered turn.
		 */
		const MAX_CONTEXT_FILES = 8;

		/**
		 * Categories the scope policy handles in the widget only.
		 */
		const WIDGET_ONLY_CATEGORIES = array( 'usage', 'setup' );

		/**
		 * Host configuration. See $defaults in the constructor for keys.
		 *
		 * @var array
		 */
		private $config;

		/**
		 * Resolved UI strings (defaults merged with host overrides).
		 *
		 * @var array
		 */
		private $strings;

		/**
		 * Constructor.
		 *
		 * @param array $config {
		 *     Host configuration.
		 *
		 *     @type string   $id               Short unique id for AJAX actions / DOM ids (e.g. 'myplugin'). Required.
		 *     @type string   $slug             Plugin slug as registered on the support hub. Required.
		 *     @type string   $version          Host plugin version. Required.
		 *     @type string   $display_name     Widget title shown in the panel header.
		 *     @type string   $endpoint_default Default endpoint base URL (no trailing slash).
		 *     @type string   $endpoint_option  wp_options key holding the admin-entered endpoint override.
		 *     @type string   $token_option     wp_options key holding the admin-entered widget token. Required.
		 *     @type string   $consent_option   wp_options key holding the first-use consent record. Required.
		 *     @type string   $capability       Capability required to see/use the widget. Default 'manage_options'.
		 *     @type string   $settings_capability Capability required to change token/endpoint. Default 'manage_options'.
		 *     @type callable $capability_check callable( string $capability ): bool — used for EVERY module capability
		 *                                      check in place of current_user_can(), so the host can route through its
		 *                                      own RBAC / access-lock layer. Optional.
		 *     @type callable $is_plugin_screen callable( WP_Screen ): bool — is this one of the host's admin screens?
		 *     @type callable $screen_context   callable(): string — short description of the current screen. Optional
		 *                                      (defaults to the WP_Screen id).
		 *     @type array    $settings_keys    Whitelisted wp_options keys to snapshot as diagnostics. Supports
		 *                                      'option' or 'option.subkey' dot notation for array options. Scalar
		 *                                      values only; anything not listed here never leaves the site.
		 *     @type callable $diagnostics      callable(): array — extra host-computed diagnostics (flat key =>
		 *                                      scalar map). Merged over the settings snapshot. Optional.
		 *     @type callable $suite            callable(): array — suite manifest [ ['slug' =>, 'version' =>], ... ].
		 *                                      Optional.
		 *     @type callable $local_ai         callable( string $system, string $user, array $opts ): string|WP_Error —
		 *                                      bridge to the host's own AI client for BYOK local answering. $opts
		 *                                      carries 'complexity' ('fast'|'smart') and 'max_tokens'. Optional; when
		 *                                      absent every chat is hub-answered AND the BYOK prompt and hint are
		 *                                      suppressed, since the host could not honour them (since 2.0.2).
		 *     @type callable $local_ai_available callable(): bool — cheap probe: does this site have a usable AI
		 *                                      key/provider right now? Optional (defaults to false when local_ai
		 *                                      is not set).
		 *     @type callable $freemius_install_id callable(): int — the site's Freemius install id for licence-based
		 *                                      hub auth (0/absent = token mode). Optional.
		 *     @type callable $activation_url   callable(): string — the host plugin's Freemius opt-in/account URL,
		 *                                      linked from the onboarding "Connect the
		 *                                      support service" prompt. Optional; when absent (e.g. token-only
		 *                                      plugins) that prompt is not shown. If local_ai is also absent the onboarding
		 *                                      view carries no call to action at all, which is correct: such a host is
		 *                                      authorised by pasting a token under its own settings screen.
		 *     @type callable $connectors_url   callable(): string — URL of the site's AI-connectors screen, linked
		 *                                      from the BYOK prompt/hint. Optional; derived generically (the WP
		 *                                      Settings screen) when absent.
		 *     @type string   $accent           CSS accent colour for the launcher/header. Default '#1B3A6B'.
		 *     @type array    $strings          UI string overrides (host passes pre-translated values).
		 * }
		 */
		public function __construct( array $config ) {
			$defaults      = array(
				'id'                  => 'plugin',
				'slug'                => '',
				'version'             => '',
				'display_name'        => 'Support Copilot',
				'endpoint_default'    => '',
				'endpoint_option'     => '',
				'token_option'        => '',
				'consent_option'      => '',
				'capability'          => 'manage_options',
				'settings_capability' => 'manage_options',
				'capability_check'    => null,
				'is_plugin_screen'    => null,
				'screen_context'      => null,
				'settings_keys'       => array(),
				'diagnostics'         => null,
				'suite'               => null,
				'local_ai'            => null,
				'local_ai_available'  => null,
				'freemius_install_id' => null,
				'activation_url'      => null,
				'connectors_url'      => null,
				'accent'              => '#1B3A6B',
				'strings'             => array(),
			);
			$this->config  = array_merge( $defaults, $config );
			$this->strings = array_merge( $this->default_strings(), (array) $this->config['strings'] );
		}

		/**
		 * Default (English) UI strings. Hosts override via config['strings'].
		 *
		 * @return array
		 */
		private function default_strings() {
			return array(
				'launcher_label'                   => 'Help',
				'launcher_title'                   => 'Open the support assistant (answers usage and setup questions on this screen, no page change)',
				'panel_subtitle'                   => 'Instant answers about this plugin',
				'consent_heading'                  => 'Before you start',
				'consent_body'                     => 'To answer your questions, this assistant sends your message, the chat history and a small read-only diagnostic snapshot — the plugin version, the versions of installed companion plugins, the admin screen you are on, and a short whitelist of non-sensitive settings — to the plugin author\'s support service at %host%. No API keys, passwords, contact records or site content are ever sent, and the assistant cannot change anything on your site. Chats are not stored on the support server; only if you later choose "Send to support" does the transcript become a support ticket.',
				'consent_accept'                   => 'Agree and start chatting',
				'consent_accept_hint'              => 'Records your agreement for this whole site and enables the assistant for everyone who can use it. Only a user with settings access can accept. You can withdraw consent on the settings page.',
				'greeting'                         => 'Hi! Ask me anything about setting up or using this plugin. I answer from documentation matched to your installed version.',
				'input_placeholder'                => 'Type your question…',
				'send'                             => 'Send',
				'thinking'                         => 'Thinking…',
				'error_generic'                    => 'Sorry — the assistant could not be reached. Please try again in a moment.',
				'soft_gate'                        => 'Usage and setup questions are handled right here in the assistant. Support tickets are prioritised for bugs, billing and data issues.',
				'escalate_anyway'                  => 'Still need to raise a ticket?',
				'escalate_offer'                   => 'Not solved? You can send this conversation to the support team.',
				'escalate_button'                  => 'Send to support',
				'escalate_confirm_label'           => 'Your email for ticket replies',
				'escalate_confirm_hint'            => 'The full chat transcript and the diagnostic snapshot are attached to the ticket so support can see what was already tried.',
				'escalate_send'                    => 'Create ticket',
				'escalate_cancel'                  => 'Cancel',
				'escalated_prefix'                 => 'Ticket created:',
				'close_title'                      => 'Close the assistant (your conversation is kept until you leave this page)',
				'new_chat'                         => 'New chat',
				'new_chat_title'                   => 'Clear this conversation and start again',
				'disabled_notice'                  => 'The assistant is not available: this site has no AI provider configured in WordPress, and the optional support service is not connected.',
				'local_no_answer'                  => "The site's AI provider could not answer this from the documentation available. Rephrasing may help; the optional support service (see the plugin's Support settings) can also answer questions and reach human support.",
				'escalate_needs_service'           => 'Sending a conversation to human support uses the support service, which is not connected on this site.',
				'answered_local'                   => "Answered by your site's AI",
				'answered_local_title'             => 'This reply was generated by the AI provider configured on this site, at your expense, using knowledge fetched from the support service.',
				'allowance_left'                   => '%d free assistant answer(s) left this month on this site.',
				'consent_denied'                   => 'Only a user who can manage this plugin\'s settings can enable the assistant for this site.',
				'settings_error_cap'               => 'You are not allowed to change support assistant settings.',
				'settings_error_nonce'             => 'Security check failed — please reload the page and try again.',
				'settings_error_https'             => 'The support endpoint must be an https:// URL.',
				'settings_token_label'             => 'Support token',
				'settings_token_ph_set'            => '•••••••• (a token is set — leave blank to keep it)',
				'settings_token_ph_unset'          => 'Paste the support-service token, if you have one',
				'settings_token_desc'              => 'Authorises this site\'s assistant with the plugin author\'s support service. The assistant stays %state% until a valid token is entered. The token is stored in this site\'s database and sent only to the support endpoint below — never to the browser.',
				'settings_state_enabled'           => 'enabled',
				'settings_state_disabled'          => 'disabled',
				'settings_token_clear_label'       => 'Clear the stored token (turns the assistant off on every screen).',
				'settings_endpoint_label'          => 'Support endpoint',
				'settings_endpoint_desc'           => 'Base URL of the support service the assistant talks to (must be https://). Leave blank to use the default shown below. Only change this if the plugin author tells you to.',
				'settings_mode_label'              => 'Answer mode',
				'settings_mode_local_title'        => 'This site\'s own AI (detected)',
				'settings_mode_local_desc'         => 'Questions are answered locally on the AI provider configured on this site, at this site\'s expense. The support service is used only to fetch version-matched knowledge files, and as an automatic fallback if the local AI fails. Locally answered replies are labelled in the chat.',
				'settings_mode_hub_title'          => 'Support service',
				'settings_mode_hub_desc'           => 'No local AI provider is configured on this site, so questions are answered by the plugin author\'s support service. If this site later gains its own AI key, the assistant switches to local answering automatically — no setting needed here.',
				'settings_consent_label'           => 'Privacy consent',
				'settings_consent_accepted'        => 'Accepted on this site on %date%.',
				'settings_consent_accepted_nodate' => 'Accepted on this site.',
				'settings_consent_withdraw'        => 'Withdraw consent. The assistant will show the privacy notice again before the next chat; nothing is sent to the support service until someone re-accepts.',
				'settings_consent_pending'         => 'Not yet accepted. Before the first chat, the assistant shows a notice explaining exactly what is sent (questions plus a read-only, non-sensitive diagnostic snapshot); a user with settings access must agree before anything is sent.',
				'onboard_heading'                  => 'Get instant answers in this assistant',
				'onboard_activate_body'            => "The optional support service answers questions on the plugin author's servers, grounded in documentation matched to your installed version, and can reach human support. Connecting registers this site with the service; no payment is required and you can disconnect at any time.",
				'onboard_activate_cta'             => 'Connect the support service',
				'onboard_byok_body'                => 'Prefer to keep everything on your own site? Add an AI provider under Settings → Connectors (WordPress 7.0+) and the assistant answers unlimited questions on your own key, at your expense.',
				'onboard_byok_cta'                 => 'Add your own AI',
				'byok_hint'                        => 'Add your own AI provider for unlimited answers on your own key.',
			);
		}

		// ------------------------------------------------------------------
		// Bootstrapping
		// ------------------------------------------------------------------

		/**
		 * Registers all hooks. Call once from the host plugin (admin context).
		 */
		public function init() {
			if ( ! is_admin() ) {
				return;
			}
			add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
			add_action( 'admin_footer', array( $this, 'render_widget' ) );
			add_action( 'wp_ajax_' . $this->action( 'chat' ), array( $this, 'ajax_chat' ) );
			add_action( 'wp_ajax_' . $this->action( 'escalate' ), array( $this, 'ajax_escalate' ) );
			add_action( 'wp_ajax_' . $this->action( 'consent' ), array( $this, 'ajax_consent' ) );
		}

		/**
		 * AJAX action name for a verb, namespaced per host instance.
		 *
		 * @param string $verb chat|escalate|consent.
		 * @return string
		 */
		private function action( $verb ) {
			return 'black_white_digital_support_copilot_' . $this->config['id'] . '_' . $verb;
		}

		/**
		 * Nonce action string.
		 *
		 * @return string
		 */
		private function nonce_action() {
			return 'black_white_digital_support_copilot_' . $this->config['id'] . '_nonce';
		}

		/**
		 * Nonce action string for the settings form. Emitted by
		 * render_settings_fields() and verified by save_settings(), so the
		 * module carries its own CSRF protection into every adopter.
		 *
		 * @return string
		 */
		private function settings_nonce_action() {
			return 'black_white_digital_support_copilot_' . $this->config['id'] . '_settings';
		}

		// ------------------------------------------------------------------
		// State accessors
		// ------------------------------------------------------------------

		/**
		 * Whether the widget is operational: it has SOME hub credential — an
		 * admin-entered token, or a Freemius install id from the host's SDK.
		 *
		 * @return bool
		 */
		public function is_configured() {
			// Usable when EITHER path works: the site's own AI provider (local
			// answers - no account, token or authorisation of any kind), or an
			// authorised connection to the external support service. The two
			// are deliberately independent (2.1.0): local answering never
			// depends on tokens, licences, accounts or install ids.
			return $this->local_ai_available() || $this->external_support_authorised();
		}

		/**
		 * Whether the EXTERNAL support service is authorised for this site
		 * (admin-entered token, or a Freemius install id). Controls only the
		 * external-service paths: hub-answered chat and escalation.
		 *
		 * @since 2.1.0
		 * @return bool
		 */
		public function external_support_authorised() {
			return '' !== $this->token() || $this->freemius_install_id() > 0;
		}

		/**
		 * The admin-entered widget token.
		 *
		 * @return string
		 */
		private function token() {
			return trim( (string) get_option( $this->config['token_option'], '' ) );
		}

		/**
		 * The site's Freemius install id from the host callback (0 = token mode).
		 *
		 * @return int
		 */
		private function freemius_install_id() {
			$cb = $this->config['freemius_install_id'];
			return is_callable( $cb ) ? (int) call_user_func( $cb ) : 0;
		}

		/**
		 * Whether the host reports a usable local AI (BYOK) right now.
		 *
		 * @return bool
		 */
		public function local_ai_available() {
			if ( ! is_callable( $this->config['local_ai'] ) ) {
				return false;
			}
			$cb = $this->config['local_ai_available'];
			return is_callable( $cb ) ? (bool) call_user_func( $cb ) : true;
		}

		/**
		 * Can this host answer locally at all — i.e. has it wired a local_ai
		 * bridge? This is a static property of the HOST, not of the site: it stays
		 * false for a host with no AI client of its own however the site is set up,
		 * whereas local_ai_available() asks whether a wired bridge is usable right
		 * now.
		 *
		 * The BYOK prompt and hint are gated on this, exactly as the service-connect
		 * prompt is gated on activation_url(). Without it an AI-less host would
		 * offer "add your own AI and the assistant answers on your key", which it
		 * could never honour — every chat would still be hub-answered.
		 *
		 * @since 2.0.2
		 *
		 * @return bool
		 */
		private function supports_byok() {
			return is_callable( $this->config['local_ai'] ) && '' !== $this->connectors_url();
		}

		/**
		 * The host's service opt-in URL for the onboarding "Connect the support
		 * service" prompt. Empty when the host supplies no callable (token-only
		 * plugins) — that prompt is then omitted.
		 *
		 * @return string
		 */
		private function activation_url() {
			$cb = $this->config['activation_url'];
			return is_callable( $cb ) ? (string) call_user_func( $cb ) : '';
		}

		/**
		 * URL of the site's AI-connectors screen for the BYOK prompt/hint. Uses
		 * the host callable when supplied, else a generic WordPress Settings URL
		 * (the exact AI-connectors screen is named in the accompanying copy).
		 *
		 * @return string
		 */
		private function connectors_url() {
			$cb = $this->config['connectors_url'];
			if ( is_callable( $cb ) ) {
				return (string) call_user_func( $cb );
			}
			return admin_url( 'options-general.php' );
		}

		/**
		 * Endpoint base URL: admin override or the host-supplied default.
		 *
		 * @return string
		 */
		public function endpoint_base() {
			$stored = '';
			if ( '' !== $this->config['endpoint_option'] ) {
				$stored = trim( (string) get_option( $this->config['endpoint_option'], '' ) );
			}
			$base = ( '' !== $stored ) ? $stored : (string) $this->config['endpoint_default'];
			return untrailingslashit( $base );
		}

		/**
		 * Hostname of the endpoint (used in the consent notice).
		 *
		 * @return string
		 */
		public function endpoint_host() {
			$host = wp_parse_url( $this->endpoint_base(), PHP_URL_HOST );
			return $host ? $host : $this->endpoint_base();
		}

		/**
		 * Whether an endpoint URL may be used: https only. The hub token rides
		 * on every request, so a cleartext endpoint would expose it in transit.
		 *
		 * @param string $url Endpoint base or full request URL.
		 * @return bool
		 */
		private function endpoint_allowed( $url ) {
			if ( 0 === strpos( $url, 'https://' ) ) {
				return true;
			}

			/**
			 * Filters whether a non-https support endpoint is allowed.
			 *
			 * Intended for local development only — never enable in production:
			 * the widget token would travel in cleartext.
			 *
			 * @param bool   $allow Default false.
			 * @param string $url   The endpoint URL being checked.
			 * @param string $id    The copilot instance id.
			 */
			return (bool) apply_filters( 'black_white_digital_support_copilot_allow_insecure_endpoint', false, $url, $this->config['id'] );
		}

		/**
		 * Whether first-use consent has been recorded for this site.
		 *
		 * @return bool
		 */
		public function consent_accepted() {
			$consent = get_option( $this->config['consent_option'], array() );
			return is_array( $consent ) && ! empty( $consent['accepted'] );
		}

		/**
		 * The stored consent record (empty array if none).
		 *
		 * @return array
		 */
		public function consent_info() {
			$consent = get_option( $this->config['consent_option'], array() );
			return is_array( $consent ) ? $consent : array();
		}

		/**
		 * Withdraws site consent (chat re-gates on next use).
		 */
		public function withdraw_consent() {
			delete_option( $this->config['consent_option'] );
		}

		/**
		 * Whether the current user may use the widget.
		 *
		 * @return bool
		 */
		private function user_can_use() {
			return $this->can( $this->config['capability'] );
		}

		/**
		 * Capability check, routed through the host's capability_check callable
		 * when provided so host RBAC / access locks apply to the module (a raw
		 * current_user_can() would bypass them).
		 *
		 * @param string $capability Capability name.
		 * @return bool
		 */
		private function can( $capability ) {
			$cb = $this->config['capability_check'];
			if ( is_callable( $cb ) ) {
				return (bool) call_user_func( $cb, (string) $capability );
			}
			return current_user_can( $capability );
		}

		/**
		 * Whether the current admin screen belongs to the host plugin.
		 *
		 * @return bool
		 */
		private function on_plugin_screen() {
			if ( ! function_exists( 'get_current_screen' ) ) {
				return false;
			}
			$screen = get_current_screen();
			if ( ! $screen ) {
				return false;
			}
			$cb = $this->config['is_plugin_screen'];
			return is_callable( $cb ) ? (bool) call_user_func( $cb, $screen ) : false;
		}

		/**
		 * Short description of the current screen, sent as chat context.
		 *
		 * @return string
		 */
		private function screen_context() {
			$cb = $this->config['screen_context'];
			if ( is_callable( $cb ) ) {
				return (string) call_user_func( $cb );
			}
			if ( function_exists( 'get_current_screen' ) ) {
				$screen = get_current_screen();
				if ( $screen ) {
					return (string) $screen->id;
				}
			}
			return '';
		}

		// ------------------------------------------------------------------
		// Assets + markup
		// ------------------------------------------------------------------

		/**
		 * Enqueues the widget CSS/JS on the host plugin's admin screens only.
		 *
		 * Assets load for capable users on the host's screens regardless of
		 * state: a site with neither a configured AI provider nor a service
		 * connection sees the onboarding view rather than nothing. Local
		 * answering needs no authorisation; the external-service endpoints
		 * check their own authorisation server-side.
		 */
		public function enqueue_assets() {
			if ( ! $this->user_can_use() || ! $this->on_plugin_screen() ) {
				return;
			}

			$dir_url  = plugin_dir_url( __FILE__ );
			$dir_path = plugin_dir_path( __FILE__ );
			$handle   = 'bwdsc-copilot-' . $this->config['id'];

			$css_ver = file_exists( $dir_path . 'support-copilot.css' ) ? (string) filemtime( $dir_path . 'support-copilot.css' ) : self::MODULE_VERSION;
			$js_ver  = file_exists( $dir_path . 'support-copilot.js' ) ? (string) filemtime( $dir_path . 'support-copilot.js' ) : self::MODULE_VERSION;

			wp_enqueue_style( $handle, $dir_url . 'support-copilot.css', array(), $css_ver );
			wp_enqueue_script( $handle, $dir_url . 'support-copilot.js', array(), $js_ver, true );
		}

		/**
		 * Renders the launcher button + popup panel in the admin footer of the
		 * host plugin's screens. All state the JS needs rides on a JSON config
		 * data attribute so multiple copilot instances can coexist on one page.
		 */
		public function render_widget() {
			if ( ! $this->user_can_use() || ! $this->on_plugin_screen() ) {
				return;
			}

			$configured     = $this->is_configured();
			$consented      = $this->consent_accepted();
			$activation_url = $this->activation_url();
			$connectors_url = $this->connectors_url();
			// The service-connect prompt needs a host-supplied opt-in URL. It only
			// ever shows inside the onboarding view, which itself only shows while
			// the external support service is not connected for this site.
			$show_activate = ( '' !== $activation_url );
			// The BYOK prompt needs the host to have wired a local_ai bridge; a
			// host without one can never answer on the site's own key.
			$show_byok = $this->supports_byok();

			$user   = wp_get_current_user();
			$s      = $this->strings;
			$js_cfg = array(
				'ajaxUrl'        => admin_url( 'admin-ajax.php' ),
				'nonce'          => wp_create_nonce( $this->nonce_action() ),
				'actionChat'     => $this->action( 'chat' ),
				'actionEscalate' => $this->action( 'escalate' ),
				'actionConsent'  => $this->action( 'consent' ),
				'consented'      => $this->consent_accepted(),
				'email'          => $user ? (string) $user->user_email : '',
				'maxHistory'     => self::MAX_HISTORY,
				'strings'        => $s,
			);

			$consent_body = str_replace( '%host%', $this->endpoint_host(), $s['consent_body'] );
			?>
			<div class="bwdsc-root"
				id="bwdsc-<?php echo esc_attr( $this->config['id'] ); ?>"
				data-bwdsc-config="<?php echo esc_attr( wp_json_encode( $js_cfg ) ); ?>"
				style="--bwdsc-accent:<?php echo esc_attr( $this->config['accent'] ); ?>;">

				<button type="button" class="bwdsc-launcher" title="<?php echo esc_attr( $s['launcher_title'] ); ?>">
					<span class="bwdsc-launcher__icon" aria-hidden="true">?</span>
					<span class="bwdsc-launcher__label"><?php echo esc_html( $s['launcher_label'] ); ?></span>
				</button>

				<div class="bwdsc-panel" hidden>
					<div class="bwdsc-panel__header">
						<div>
							<strong><?php echo esc_html( $this->config['display_name'] ); ?></strong>
							<span class="bwdsc-panel__subtitle"><?php echo esc_html( $s['panel_subtitle'] ); ?></span>
						</div>
						<div class="bwdsc-panel__header-actions">
							<button type="button" class="bwdsc-newchat" title="<?php echo esc_attr( $s['new_chat_title'] ); ?>"><?php echo esc_html( $s['new_chat'] ); ?></button>
							<button type="button" class="bwdsc-close" title="<?php echo esc_attr( $s['close_title'] ); ?>" aria-label="Close">&times;</button>
						</div>
					</div>

					<div class="bwdsc-onboard" <?php echo $configured ? 'hidden' : ''; ?>>
						<h3><?php echo esc_html( $s['onboard_heading'] ); ?></h3>
						<?php if ( $show_activate ) : ?>
							<div class="bwdsc-onboard__card">
								<p><?php echo esc_html( $s['onboard_activate_body'] ); ?></p>
								<a class="button button-primary bwdsc-onboard__cta" href="<?php echo esc_url( $activation_url ); ?>"><?php echo esc_html( $s['onboard_activate_cta'] ); ?></a>
							</div>
						<?php endif; ?>
						<?php if ( $show_byok ) : ?>
							<div class="bwdsc-onboard__card">
								<p><?php echo esc_html( $s['onboard_byok_body'] ); ?></p>
								<a class="button bwdsc-onboard__cta" href="<?php echo esc_url( $connectors_url ); ?>"><?php echo esc_html( $s['onboard_byok_cta'] ); ?></a>
							</div>
						<?php endif; ?>
					</div>

					<div class="bwdsc-consent" <?php echo ( $configured && ! $consented ) ? '' : 'hidden'; ?>>
						<h3><?php echo esc_html( $s['consent_heading'] ); ?></h3>
						<p><?php echo esc_html( $consent_body ); ?></p>
						<button type="button" class="bwdsc-consent__accept button button-primary" title="<?php echo esc_attr( $s['consent_accept_hint'] ); ?>">
							<?php echo esc_html( $s['consent_accept'] ); ?>
						</button>
					</div>

					<div class="bwdsc-chat" <?php echo ( $configured && $consented ) ? '' : 'hidden'; ?>>
						<div class="bwdsc-messages" aria-live="polite">
							<div class="bwdsc-msg bwdsc-msg--assistant"><?php echo esc_html( $s['greeting'] ); ?></div>
						</div>
						<?php if ( $show_byok && ! $this->local_ai_available() && '' !== (string) $s['byok_hint'] ) : ?>
							<div class="bwdsc-byokhint">
								<a href="<?php echo esc_url( $connectors_url ); ?>"><?php echo esc_html( $s['byok_hint'] ); ?></a>
							</div>
						<?php endif; ?>
						<div class="bwdsc-followup" hidden></div>
						<div class="bwdsc-status" hidden></div>
						<div class="bwdsc-inputrow">
							<textarea class="bwdsc-input" rows="2" placeholder="<?php echo esc_attr( $s['input_placeholder'] ); ?>"></textarea>
							<button type="button" class="bwdsc-send button button-primary"><?php echo esc_html( $s['send'] ); ?></button>
						</div>
					</div>
				</div>
			</div>
			<?php
		}

		// ------------------------------------------------------------------
		// AJAX proxies (token stays server-side; browser never sees it)
		// ------------------------------------------------------------------

		/**
		 * Common AJAX guard: capability + nonce.
		 *
		 * @return bool True when the request may proceed (otherwise a JSON error
		 *              has been sent).
		 */
		private function guard_ajax() {
			$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
			if ( ! wp_verify_nonce( $nonce, $this->nonce_action() ) ) {
				wp_send_json_error( array( 'message' => 'Security check failed.' ), 403 );
				return false;
			}
			if ( ! $this->user_can_use() ) {
				wp_send_json_error( array( 'message' => 'Security check failed.' ), 403 );
				return false;
			}
			return true;
		}

		/**
		 * AJAX: record first-use consent for this site.
		 */
		public function ajax_consent() {
			if ( ! $this->guard_ajax() ) {
				return;
			}
			// Consent is a site-wide privacy decision: gate it on the settings
			// capability so grant and withdrawal (settings page) are symmetric.
			if ( ! $this->can( $this->config['settings_capability'] ) ) {
				wp_send_json_error( array( 'message' => $this->strings['consent_denied'] ), 403 );
				return;
			}
			update_option(
				$this->config['consent_option'],
				array(
					'accepted' => 1,
					'user_id'  => get_current_user_id(),
					'time'     => time(),
					'host'     => $this->endpoint_host(),
				),
				false
			);
			wp_send_json_success( array( 'consented' => true ) );
		}

		/**
		 * AJAX: answer one chat turn. BYOK-first: when the host has a usable
		 * local AI the answer is generated on the site's own key from hub-served
		 * knowledge; otherwise (or on any local failure) the hub answers.
		 */
		public function ajax_chat() {
			if ( ! $this->guard_ajax() ) {
				return;
			}
			if ( ! $this->is_configured() ) {
				wp_send_json_error( array( 'message' => $this->strings['disabled_notice'] ), 400 );
				return;
			}
			if ( ! $this->consent_accepted() ) {
				wp_send_json_error( array( 'message' => 'Consent has not been recorded for this site.' ), 403 );
				return;
			}

			$message = sanitize_textarea_field( wp_unslash( $_POST['message'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in guard_ajax().
			if ( '' === trim( $message ) ) {
				wp_send_json_error( array( 'message' => 'Empty message.' ), 400 );
				return;
			}

			$history = $this->read_history();

			// Tier 1: local answer on the site's own AI provider. Never gated
			// on tokens, licences or accounts (2.1.0); the hub is used only as
			// a version-matched documentation source.
			if ( $this->local_ai_available() ) {
				$local = $this->try_local_answer( $message, $history );
				if ( is_array( $local ) ) {
					wp_send_json_success( $local );
					return;
				}
			}

			// Tier 2: the external support service answers - only where that
			// separate service is authorised for this site.
			if ( ! $this->external_support_authorised() ) {
				wp_send_json_error( array( 'message' => $this->strings['local_no_answer'] ), 200 );
				return;
			}
			$payload            = $this->base_payload();
			$payload['message'] = $message;
			$payload['history'] = $history;

			$data = $this->hub_request( '/copilot', $payload );
			if ( is_wp_error( $data ) ) {
				wp_send_json_error( array( 'message' => $data->get_error_message() ) );
				return;
			}

			wp_send_json_success(
				array(
					'reply'               => (string) ( $data['reply'] ?? '' ),
					'category_guess'      => (string) ( $data['category_guess'] ?? 'other' ),
					'escalation_offered'  => ! empty( $data['escalation_offered'] ),
					'tier'                => (string) ( $data['tier'] ?? '' ),
					'remaining_allowance' => isset( $data['remaining_allowance'] ) && null !== $data['remaining_allowance'] ? (int) $data['remaining_allowance'] : null,
					'answered_by'         => 'hub',
				)
			);
		}

		/**
		 * AJAX: escalate the chat to a support ticket.
		 */
		public function ajax_escalate() {
			if ( ! $this->guard_ajax() ) {
				return;
			}
			if ( ! $this->external_support_authorised() ) {
				wp_send_json_error( array( 'message' => $this->strings['escalate_needs_service'] ), 400 );
				return;
			}
			if ( ! $this->consent_accepted() ) {
				wp_send_json_error( array( 'message' => 'Consent has not been recorded for this site.' ), 403 );
				return;
			}

			$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in guard_ajax().
			if ( ! is_email( $email ) ) {
				wp_send_json_error( array( 'message' => 'A valid email address is required.' ), 400 );
				return;
			}

			$history = $this->read_history();
			if ( empty( $history ) ) {
				wp_send_json_error( array( 'message' => 'There is no conversation to send yet.' ), 400 );
				return;
			}

			$user = wp_get_current_user();

			$payload               = $this->base_payload();
			$payload['history']    = $history;
			$payload['category']   = sanitize_key( wp_unslash( $_POST['category'] ?? 'other' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in guard_ajax().
			$payload['email']      = $email;
			$payload['first_name'] = $user ? (string) $user->first_name : '';
			$payload['last_name']  = $user ? (string) $user->last_name : '';

			$data = $this->hub_request( '/copilot-escalate', $payload );
			if ( is_wp_error( $data ) ) {
				wp_send_json_error( array( 'message' => $data->get_error_message() ) );
				return;
			}

			wp_send_json_success(
				array(
					'ticket_id' => (int) ( $data['ticket_id'] ?? 0 ),
					'message'   => (string) ( $data['message'] ?? '' ),
				)
			);
		}

		/**
		 * Reads + sanitizes the JSON history param from the AJAX request.
		 *
		 * @return array[] [ [role, content], ... ] capped at MAX_HISTORY turns.
		 */
		private function read_history() {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- nonce verified in guard_ajax(); JSON decoded and each turn sanitized below.
			$raw   = json_decode( (string) wp_unslash( $_POST['history'] ?? '[]' ), true );
			$turns = array();
			if ( ! is_array( $raw ) ) {
				return $turns;
			}
			foreach ( array_slice( $raw, - self::MAX_HISTORY ) as $turn ) {
				if ( ! is_array( $turn ) ) {
					continue;
				}
				$role    = ( 'assistant' === ( $turn['role'] ?? '' ) ) ? 'assistant' : 'user';
				$content = sanitize_textarea_field( (string) ( $turn['content'] ?? '' ) );
				if ( '' === trim( $content ) ) {
					continue;
				}
				$turns[] = array(
					'role'    => $role,
					'content' => mb_substr( $content, 0, 4000 ),
				);
			}
			return $turns;
		}

		// ------------------------------------------------------------------
		// Hub transport + diagnostics
		// ------------------------------------------------------------------

		/**
		 * Payload fields common to both hub endpoints.
		 *
		 * @return array
		 */
		private function base_payload() {
			// The current admin screen is captured client-side at page load and
			// echoed back per message (AJAX requests have no WP_Screen).
			$screen = sanitize_text_field( wp_unslash( $_POST['screen'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guarded in guard_ajax().

			$payload = array(
				'slug'        => $this->config['slug'],
				'token'       => $this->token(),
				'site'        => home_url(),
				'version'     => $this->config['version'],
				'screen'      => $screen,
				'suite'       => $this->suite_manifest(),
				'diagnostics' => $this->diagnostics(),
			);

			// Freemius licence auth wins over the token when the host supplies
			// an install id (hub-side precedence; see authenticate() on the hub).
			$install_id = $this->freemius_install_id();
			if ( $install_id > 0 ) {
				$payload['freemius_install_id'] = $install_id;
			}

			return $payload;
		}

		/**
		 * POSTs JSON to the hub and returns the decoded body or a WP_Error with
		 * a user-facing message.
		 *
		 * @param string $path    Route path, e.g. '/copilot'.
		 * @param array  $payload Request body.
		 * @param int    $timeout Request timeout in seconds. AI answering needs
		 *                        a long one; knowledge/manifest fetches do not.
		 * @return array|WP_Error
		 */
		private function hub_request( $path, array $payload, $timeout = 60 ) {
			$url = $this->endpoint_base() . $path;
			if ( ! $this->endpoint_allowed( $url ) ) {
				return new \WP_Error( 'bwdsc_endpoint_https', $this->strings['settings_error_https'] );
			}

			// wp_safe_remote_post(), not wp_remote_post(): the endpoint is
			// administrator-configurable, so the destination is user input. The safe
			// variant runs it through wp_http_validate_url(), which rejects private,
			// loopback and link-local addresses and non-standard ports, and applies
			// the same validation to redirects. endpoint_allowed() above only
			// enforces the https scheme; it says nothing about the host, so on its
			// own it would not stop a request being aimed at internal
			// infrastructure. Pointing a host at a genuinely local support hub for
			// development needs core's own `http_request_host_is_external` filter.
			$response = wp_safe_remote_post(
				$url,
				array(
					'headers'             => array( 'Content-Type' => 'application/json' ),
					'body'                => wp_json_encode( $payload ),
					'timeout'             => (int) $timeout,
					'limit_response_size' => 2 * MB_IN_BYTES,
				)
			);

			if ( is_wp_error( $response ) ) {
				return new \WP_Error( 'bwdsc_network', $this->strings['error_generic'] );
			}

			$code = (int) wp_remote_retrieve_response_code( $response );
			$data = json_decode( wp_remote_retrieve_body( $response ), true );

			if ( 200 !== $code ) {
				// REST errors carry a human-readable message (rate limits, bad
				// token, unknown slug). Surface it; fall back to the generic one.
				$msg = is_array( $data ) && ! empty( $data['message'] ) ? (string) $data['message'] : $this->strings['error_generic'];
				return new \WP_Error( 'bwdsc_hub_' . $code, $msg );
			}

			return is_array( $data ) ? $data : array();
		}

		/**
		 * Builds the read-only diagnostics snapshot: whitelisted settings keys
		 * plus any host-computed extras. Scalars only — anything else is dropped.
		 *
		 * @return array
		 */
		private function diagnostics() {
			$out = array();

			foreach ( (array) $this->config['settings_keys'] as $key ) {
				$key = (string) $key;
				if ( false !== strpos( $key, '.' ) ) {
					list( $option, $sub ) = explode( '.', $key, 2 );
					$value                = get_option( $option, array() );
					$value                = is_array( $value ) ? ( $value[ $sub ] ?? null ) : null;
				} else {
					$value = get_option( $key, null );
				}
				if ( is_scalar( $value ) ) {
					$out[ $key ] = is_bool( $value ) ? ( $value ? '1' : '0' ) : (string) $value;
				}
			}

			$cb = $this->config['diagnostics'];
			if ( is_callable( $cb ) ) {
				foreach ( (array) call_user_func( $cb ) as $k => $v ) {
					if ( is_scalar( $v ) ) {
						$out[ (string) $k ] = is_bool( $v ) ? ( $v ? '1' : '0' ) : (string) $v;
					}
				}
			}

			return $out;
		}

		/**
		 * Suite manifest from the host callback.
		 *
		 * @return array
		 */
		private function suite_manifest() {
			$cb = $this->config['suite'];
			if ( ! is_callable( $cb ) ) {
				return array();
			}
			$out = array();
			foreach ( (array) call_user_func( $cb ) as $entry ) {
				if ( is_array( $entry ) && ! empty( $entry['slug'] ) ) {
					$out[] = array(
						'slug'    => (string) $entry['slug'],
						'version' => (string) ( $entry['version'] ?? '' ),
					);
				}
			}
			return $out;
		}

		// ------------------------------------------------------------------
		// Local (BYOK) answering — hub serves knowledge, the site's AI answers
		// ------------------------------------------------------------------

		/**
		 * Attempts a locally-answered turn: manifest -> local relevance pick ->
		 * file fetch -> local answer on the site's own AI. Returns the response
		 * array for the JS, or null on ANY failure so the caller falls back to
		 * hub answering. Never sends the user's question to the hub.
		 *
		 * @param string $message Current user message.
		 * @param array  $history Sanitized prior turns.
		 * @return array|null
		 */
		private function try_local_answer( $message, array $history ) {
			$manifest = $this->get_manifest_cached();
			if ( empty( $manifest ) ) {
				return null;
			}

			$ids = $this->pick_relevant_ids( $message, $history, $manifest );
			if ( empty( $ids ) ) {
				return null;
			}

			$files = $this->context_request( 'files', $ids );
			if ( is_wp_error( $files ) || empty( $files ) ) {
				return null;
			}

			// want=files returns {id, content} only; names come from the manifest.
			$names = array();
			foreach ( $manifest as $entry ) {
				if ( ! empty( $entry['id'] ) ) {
					$names[ (string) $entry['id'] ] = (string) ( $entry['name'] ?? $entry['id'] );
				}
			}

			$context = '';
			foreach ( $files as $file ) {
				if ( empty( $file['id'] ) || ! isset( $file['content'] ) ) {
					continue;
				}
				$label    = $names[ (string) $file['id'] ] ?? (string) $file['id'];
				$context .= '----- FILE: ' . $label . " -----\n" . $file['content'] . "\n\n";
			}
			if ( '' === $context ) {
				return null;
			}

			$reply = $this->call_local_ai(
				$this->local_system_prompt( $context ),
				$this->flatten_history( $history, $message ),
				array(
					'complexity' => 'smart',
					'max_tokens' => 1400,
				)
			);
			if ( null === $reply || '' === trim( $reply ) ) {
				return null;
			}

			list( $reply, $category ) = $this->split_category( $reply );

			return array(
				'reply'               => $reply,
				'category_guess'      => $category,
				'escalation_offered'  => ! in_array( $category, self::WIDGET_ONLY_CATEGORIES, true ),
				'tier'                => '',
				'remaining_allowance' => null,
				'answered_by'         => 'local',
			);
		}

		/**
		 * Calls the host's AI bridge; normalises errors to null.
		 *
		 * @param string $system System prompt.
		 * @param string $user   User content.
		 * @param array  $opts   complexity / max_tokens.
		 * @return string|null
		 */
		private function call_local_ai( $system, $user, array $opts ) {
			$cb = $this->config['local_ai'];
			if ( ! is_callable( $cb ) ) {
				return null;
			}
			$result = call_user_func( $cb, $system, $user, $opts );
			return ( is_wp_error( $result ) || ! is_string( $result ) ) ? null : $result;
		}

		/**
		 * Fetches the hub knowledge manifest, transient-cached per plugin
		 * version (context calls share the hub's per-site rate limit).
		 *
		 * @return array [ [id, name, size, set_label], ... ]
		 */
		private function get_manifest_cached() {
			$key    = 'black_white_digital_support_copilot_' . $this->config['id'] . '_manifest_' . md5( $this->config['version'] . '|' . $this->endpoint_base() );
			$cached = get_transient( $key );
			if ( is_array( $cached ) ) {
				return $cached;
			}

			$files = $this->context_request( 'manifest' );
			if ( is_wp_error( $files ) || ! is_array( $files ) ) {
				return array();
			}

			set_transient( $key, $files, self::MANIFEST_TTL );
			return $files;
		}

		/**
		 * POSTs to /copilot-context and returns the files array. File contents
		 * are version-stable, so want=files responses are transient-cached
		 * (30 min) — repeat questions in one conversation usually pick
		 * overlapping files.
		 *
		 * @param string $want 'manifest' or 'files'.
		 * @param array  $ids  File ids for want=files.
		 * @return array|WP_Error
		 */
		private function context_request( $want, array $ids = array() ) {
			$cache_key = '';
			if ( 'files' === $want && array() !== $ids ) {
				$sorted = array_values( $ids );
				sort( $sorted );
				$cache_key = 'black_white_digital_support_copilot_' . $this->config['id'] . '_files_' . md5( $this->config['version'] . '|' . implode( ',', $sorted ) );
				$cached    = get_transient( $cache_key );
				if ( is_array( $cached ) ) {
					return $cached;
				}
			}

			$payload         = $this->base_payload();
			$payload['want'] = $want;
			unset( $payload['suite'], $payload['diagnostics'] ); // Knowledge fetch needs auth only, not site state.
			if ( array() !== $ids ) {
				$payload['ids'] = array_values( $ids );
			}

			$data = $this->hub_request( '/copilot-context', $payload, 15 );
			if ( is_wp_error( $data ) ) {
				return $data;
			}
			$files = isset( $data['files'] ) && is_array( $data['files'] ) ? $data['files'] : array();

			if ( '' !== $cache_key && array() !== $files ) {
				set_transient( $cache_key, $files, 30 * MINUTE_IN_SECONDS );
			}

			return $files;
		}

		/**
		 * Local relevance pick: the site's own AI chooses which knowledge files
		 * to fetch for this question (fast/cheap call, strict JSON out).
		 *
		 * @param string $message  Current user message.
		 * @param array  $history  Prior turns (recent ones inform the pick).
		 * @param array  $manifest Manifest entries.
		 * @return string[] File ids, capped at MAX_CONTEXT_FILES.
		 */
		private function pick_relevant_ids( $message, array $history, array $manifest ) {
			$listing = '';
			$valid   = array();
			foreach ( $manifest as $entry ) {
				if ( empty( $entry['id'] ) ) {
					continue;
				}
				$valid[ (string) $entry['id'] ] = true;
				$listing                       .= $entry['id'] . ' | ' . ( $entry['name'] ?? '' ) . ' | ' . ( $entry['set_label'] ?? '' ) . "\n";
			}
			if ( '' === $listing ) {
				return array();
			}

			$question = $message;
			foreach ( array_slice( $history, -4 ) as $turn ) {
				$question .= "\n" . $turn['content'];
			}

			$system = 'You select documentation files relevant to a support question. Respond ONLY with a JSON array of file id strings (no other text, no markdown). Pick at most ' . self::MAX_CONTEXT_FILES . ' ids, most relevant first.';
			$user   = "QUESTION:\n" . $question . "\n\nAVAILABLE FILES (id | name | set):\n" . $listing;

			$raw = $this->call_local_ai(
				$system,
				$user,
				array(
					'complexity' => 'fast',
					'max_tokens' => 400,
				)
			);
			if ( null === $raw ) {
				return array();
			}

			// Tolerate fenced or prefixed output: grab the first JSON array.
			if ( ! preg_match( '/\[[^\]]*\]/s', $raw, $m ) ) {
				return array();
			}
			$picked = json_decode( $m[0], true );
			if ( ! is_array( $picked ) ) {
				return array();
			}

			$ids = array();
			foreach ( $picked as $id ) {
				if ( is_string( $id ) && isset( $valid[ $id ] ) ) {
					$ids[] = $id;
				}
				if ( count( $ids ) >= self::MAX_CONTEXT_FILES ) {
					break;
				}
			}
			return $ids;
		}

		/**
		 * The local system prompt — mirrors the hub's scope-policy prompt
		 * (advisor-only rules, widget-only tiering, trailing category marker)
		 * so behaviour is identical whichever tier answers.
		 *
		 * @param string $context Fetched knowledge file contents.
		 * @return string
		 */
		private function local_system_prompt( $context ) {
			$name = (string) $this->config['display_name'];

			$p  = 'You are the ' . $name . ' — an in-dashboard help assistant for a WordPress plugin. ';
			$p .= "You chat with the site administrator inside their WordPress dashboard and help them use, set up and troubleshoot the plugin.\n\n";
			$p .= "Rules:\n";
			$p .= "- Advisor only. Explain steps in the WordPress admin (refer to screens by menu path), but NEVER propose code edits, file changes, database queries, or shell commands, and never ask for credentials or API keys.\n";
			$p .= "- Answer from the plugin context and the site snapshot provided below. If they do not cover the question, say so plainly rather than guessing.\n";
			$p .= "- Usage and setup questions are fully handled here in this assistant. Support tickets are reserved for bugs, licence/billing and data problems. If the user asks to contact support with a usage question, politely explain that this assistant is the usage-support channel and keep helping.\n";
			$p .= "- Be concise and step-by-step. One question at a time if you need clarification.\n";
			$p .= "- End EVERY reply with a final line of exactly this form and nothing after it: [[category:X]] where X is one of usage, setup, bug, billing, data, feature, other - your best classification of the user's CURRENT question. This line is stripped before display.\n\n";

			$snapshot = $this->snapshot_text();
			if ( '' !== $snapshot ) {
				$p .= "SITE SNAPSHOT:\n" . $snapshot . "\n\n";
			}

			if ( '' !== $context ) {
				$p .= "PLUGIN DOCUMENTATION AND CODE CONTEXT:\n\n" . $context . "\n";
			}

			return $p;
		}

		/**
		 * The diagnostics snapshot as prompt text (same shape the hub builds
		 * from the request fields).
		 *
		 * @return string
		 */
		private function snapshot_text() {
			$screen = sanitize_text_field( wp_unslash( $_POST['screen'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guarded in guard_ajax().

			$lines   = array();
			$lines[] = 'Plugin version on this site: ' . $this->config['version'];
			if ( '' !== $screen ) {
				$lines[] = 'Admin screen the user is on: ' . $screen;
			}

			$suite = $this->suite_manifest();
			if ( $suite ) {
				$parts = array();
				foreach ( $suite as $p ) {
					$parts[] = $p['slug'] . ' ' . $p['version'];
				}
				$lines[] = 'Suite plugins installed: ' . implode( ', ', $parts );
			}

			$diag = $this->diagnostics();
			if ( $diag ) {
				$text = '';
				foreach ( $diag as $k => $v ) {
					$text .= $k . ': ' . $v . "\n";
				}
				$lines[] = "Settings/diagnostics snapshot:\n" . $text;
			}

			return implode( "\n", $lines );
		}

		/**
		 * Splits the trailing [[category:x]] marker off a reply (mirrors the
		 * hub's parser so local and hub replies behave identically).
		 *
		 * @param string $text Raw model reply.
		 * @return array [ reply, category ]
		 */
		private function split_category( $text ) {
			$category = 'other';
			if ( preg_match( '/\[\[category:([a-z]+)\]\]\s*$/i', $text, $m ) ) {
				$known    = array( 'usage', 'setup', 'bug', 'billing', 'data', 'feature', 'other' );
				$guess    = strtolower( $m[1] );
				$category = in_array( $guess, $known, true ) ? $guess : 'other';
				$text     = trim( preg_replace( '/\[\[category:[a-z]+\]\]\s*$/i', '', $text ) );
			}
			return array( trim( $text ), $category );
		}

		/**
		 * Folds prior turns + the current message into one user block for
		 * single-turn local AI bridges.
		 *
		 * @param array  $history Sanitized prior turns.
		 * @param string $message Current user message.
		 * @return string
		 */
		private function flatten_history( array $history, $message ) {
			$lines = array();
			foreach ( array_slice( $history, -10 ) as $turn ) {
				$label   = ( 'assistant' === $turn['role'] ) ? 'Assistant' : 'User';
				$lines[] = $label . ': ' . $turn['content'];
			}
			$lines[] = 'User: ' . trim( $message );
			return implode( "\n\n", $lines );
		}

		// ------------------------------------------------------------------
		// Settings helpers (the host embeds these in its own settings page)
		// ------------------------------------------------------------------

		/**
		 * Renders the token/endpoint/consent form rows. The host wraps this in
		 * its own <form> and routes the POST to save_settings(). Every string
		 * is host-overridable (pre-translated) via config['strings'] using the
		 * settings_* keys; defaults are plain English.
		 */
		public function render_settings_fields() {
			$token_set = $this->external_support_authorised();
			$endpoint  = '';
			if ( '' !== $this->config['endpoint_option'] ) {
				$endpoint = (string) get_option( $this->config['endpoint_option'], '' );
			}
			$consent = $this->consent_info();
			$s       = $this->strings;

			// The %state% token carries the enabled/disabled word; parts are
			// escaped individually before the <strong> wrapper is injected.
			$token_state = $token_set ? $s['settings_state_enabled'] : $s['settings_state_disabled'];
			$token_desc  = str_replace(
				'%state%',
				'<strong>' . esc_html( $token_state ) . '</strong>',
				esc_html( $s['settings_token_desc'] )
			);

			// Module-level CSRF protection: save_settings() verifies this nonce
			// itself, so every adopter gets the safe path by default.
			wp_nonce_field( $this->settings_nonce_action(), 'bwdsc_settings_nonce' );
			?>
			<table class="form-table" role="presentation">
				<tr>
					<th scope="row"><label for="bwdsc_token"><?php echo esc_html( $s['settings_token_label'] ); ?></label></th>
					<td>
						<input type="text" id="bwdsc_token" name="bwdsc_token" class="regular-text" autocomplete="off"
							value="" placeholder="<?php echo esc_attr( $token_set ? $s['settings_token_ph_set'] : $s['settings_token_ph_unset'] ); ?>">
						<p class="description">
							<?php echo wp_kses( $token_desc, array( 'strong' => array() ) ); ?>
							<?php if ( $token_set ) : ?>
								<br><label><input type="checkbox" name="bwdsc_token_clear" value="1"> <?php echo esc_html( $s['settings_token_clear_label'] ); ?></label>
							<?php endif; ?>
						</p>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="bwdsc_endpoint"><?php echo esc_html( $s['settings_endpoint_label'] ); ?></label></th>
					<td>
						<input type="url" id="bwdsc_endpoint" name="bwdsc_endpoint" class="regular-text code"
							value="<?php echo esc_attr( $endpoint ); ?>"
							placeholder="<?php echo esc_attr( $this->config['endpoint_default'] ); ?>">
						<p class="description">
							<?php echo esc_html( $s['settings_endpoint_desc'] ); ?><br>
							<code><?php echo esc_html( $this->config['endpoint_default'] ); ?></code>
						</p>
					</td>
				</tr>
				<tr>
					<th scope="row"><?php echo esc_html( $s['settings_mode_label'] ); ?></th>
					<td>
						<?php if ( $this->local_ai_available() ) : ?>
							<p>
								<strong><?php echo esc_html( $s['settings_mode_local_title'] ); ?></strong> —
								<?php echo esc_html( $s['settings_mode_local_desc'] ); ?>
							</p>
						<?php else : ?>
							<p>
								<strong><?php echo esc_html( $s['settings_mode_hub_title'] ); ?></strong> —
								<?php echo esc_html( $s['settings_mode_hub_desc'] ); ?>
							</p>
						<?php endif; ?>
					</td>
				</tr>
				<tr>
					<th scope="row"><?php echo esc_html( $s['settings_consent_label'] ); ?></th>
					<td>
						<?php if ( ! empty( $consent['accepted'] ) ) : ?>
							<p>
								<span class="dashicons dashicons-yes" style="color:#00a32a;"></span>
								<?php
								if ( ! empty( $consent['time'] ) ) {
									echo esc_html( str_replace( '%date%', gmdate( 'Y-m-d', (int) $consent['time'] ), $s['settings_consent_accepted'] ) );
								} else {
									echo esc_html( $s['settings_consent_accepted_nodate'] );
								}
								?>
							</p>
							<label>
								<input type="checkbox" name="bwdsc_withdraw_consent" value="1">
								<?php echo esc_html( $s['settings_consent_withdraw'] ); ?>
							</label>
						<?php else : ?>
							<p><?php echo esc_html( $s['settings_consent_pending'] ); ?></p>
						<?php endif; ?>
					</td>
				</tr>
			</table>
			<?php
		}

		/**
		 * Persists the settings-field POST. Verifies the module's own nonce
		 * (emitted by render_settings_fields()) and the settings capability, so
		 * the CSRF-safe path is the default in every adopter; hosts may verify
		 * an outer nonce of their own as well.
		 *
		 * @param array $raw Raw POST array (slashed, as PHP provides it).
		 * @return true|WP_Error
		 */
		public function save_settings( array $raw ) {
			$nonce = sanitize_text_field( wp_unslash( $raw['bwdsc_settings_nonce'] ?? '' ) );
			if ( ! wp_verify_nonce( $nonce, $this->settings_nonce_action() ) ) {
				return new \WP_Error( 'bwdsc_nonce', $this->strings['settings_error_nonce'] );
			}
			if ( ! $this->can( $this->config['settings_capability'] ) ) {
				return new \WP_Error( 'bwdsc_cap', $this->strings['settings_error_cap'] );
			}

			// Validate everything before persisting anything.
			$endpoint = '';
			if ( '' !== $this->config['endpoint_option'] ) {
				$endpoint = esc_url_raw( wp_unslash( $raw['bwdsc_endpoint'] ?? '' ) );
				if ( '' !== $endpoint && ! $this->endpoint_allowed( $endpoint ) ) {
					return new \WP_Error( 'bwdsc_endpoint_https', $this->strings['settings_error_https'] );
				}
			}

			$previous_host = $this->endpoint_host();

			// Token: blank = keep current; explicit clear checkbox empties it.
			if ( ! empty( $raw['bwdsc_token_clear'] ) ) {
				delete_option( $this->config['token_option'] );
			} else {
				$token = sanitize_text_field( wp_unslash( $raw['bwdsc_token'] ?? '' ) );
				if ( '' !== $token ) {
					update_option( $this->config['token_option'], $token, false );
				}
			}

			if ( '' !== $this->config['endpoint_option'] ) {
				if ( '' === $endpoint ) {
					delete_option( $this->config['endpoint_option'] );
				} else {
					update_option( $this->config['endpoint_option'], untrailingslashit( $endpoint ), false );
				}
			}

			// Consent was recorded for a specific endpoint host: a change of
			// host invalidates it, so data never flows anywhere unconsented.
			if ( $this->consent_accepted() && $this->endpoint_host() !== $previous_host ) {
				$this->withdraw_consent();
			}

			if ( ! empty( $raw['bwdsc_withdraw_consent'] ) ) {
				$this->withdraw_consent();
			}

			return true;
		}
	}

endif;
