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

/**
 * The Settings page: the activation key, the check button and the usage figures.
 *
 * The site knows only the SERVICE and its own licence: no model key, no model name, no provider
 * choice. Those live on the server, where they can be changed for everybody at once instead of site
 * by site.
 */
class AIPB_Admin_Settings {

	public static function register_settings() {
		register_setting( 'aipb', AIPB_Remote::OPT_KEY, array(
			'sanitize_callback' => array( __CLASS__, 'sanitize_key' ),
			'default'           => '',
		) );

		register_setting( 'aipb', AIPB_I18n::OPT_LOCALE, array(
			'type'              => 'string',
			'sanitize_callback' => array( 'AIPB_I18n', 'sanitize' ),
			'default'           => '',
		) );

		register_setting( 'aipb_brand', AIPB_Design::OPT_DIRECTION, array(
			'type'              => 'string',
			'sanitize_callback' => array( 'AIPB_Design', 'sanitize_direction' ),
			'default'           => '',
		) );

		register_setting( 'aipb_brand', AIPB_Design::OPT_TOKENS, array(
			'type'              => 'array',
			'sanitize_callback' => array( 'AIPB_Admin_Brand', 'sanitize_tokens' ),
			'default'           => array(),
		) );

		register_setting( 'aipb_brand', AIPB_Design::OPT_CUSTOM, array(
			'type'              => 'array',
			'sanitize_callback' => array( 'AIPB_Design', 'sanitize_custom' ),
			'default'           => array(),
		) );

		register_setting( 'aipb_brand', AIPB_Design::OPT_PALETTE, array(
			'type'              => 'array',
			'sanitize_callback' => array( 'AIPB_Design', 'sanitize_palette' ),
			'default'           => array(),
		) );

		register_setting( 'aipb_brand', AIPB_Design::OPT_FONTS, array(
			'type'              => 'array',
			'sanitize_callback' => array( 'AIPB_Design', 'sanitize_fonts' ),
			'default'           => array(),
		) );

		register_setting( 'aipb_brand', AIPB_Effects::OPT, array(
			'type'              => 'array',
			'sanitize_callback' => array( 'AIPB_Effects', 'sanitize' ),
			'default'           => array(),
		) );

		register_setting( 'aipb_brand', AIPB_Design::OPT_MANIFEST, array(
			'type'              => 'array',
			'sanitize_callback' => array( 'AIPB_Design', 'sanitize_manifest' ),
			'default'           => array(),
		) );
	}

	/**
	 * Sanitizes and ENCRYPTS the activation key. The rules:
	 *  - the field never shows the real key again, so submitting it empty must NOT delete it: the
	 *    value currently saved (already encrypted) is kept;
	 *  - the "remove" checkbox empties it;
	 *  - a new key is sanitized and encrypted (it never reaches the database in the clear).
	 * It runs inside options.php, after settings_fields('aipb') has verified the nonce.
	 */
	public static function sanitize_key( $input ) {
		$current = (string) get_option( AIPB_Remote::OPT_KEY, '' );

		if ( ! empty( $_POST['aipb_server_key_clear'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing — options.php's nonce is already verified
			return '';
		}

		$input = is_string( $input ) ? trim( $input ) : '';
		if ( '' === $input ) {
			return $current; // field left empty: keep the existing key
		}

		// IDEMPOTENCE — on the first save of an option that does not exist yet, WordPress runs this
		// callback TWICE (update_option falls back to add_option, which sanitizes again). Without
		// this guard the second pass would encrypt the already-encrypted value and the key would
		// come out unreadable: the service would answer 401 to a perfectly valid licence.
		if ( AIPB_Secret::is_encrypted( $input ) ) {
			return $input;
		}

		return AIPB_Secret::encrypt( sanitize_text_field( $input ) );
	}

	/* --------------------------------- The page ------------------------------- */

	/**
	 * The page is a dashboard and not a form with a table on the end.
	 *
	 * The order is the order of the questions actually asked here: «is it working and how much have
	 * I spent» comes first because it is the one people come back for, «which key» second because it
	 * is set once, «which language» last. The introduction, which is read once and then never again,
	 * folds away instead of pushing everything below the fold.
	 */
	public static function render() {
		$has_key = ( '' !== trim( (string) get_option( AIPB_Remote::OPT_KEY, '' ) ) );

		// Asked ONCE and handed to everyone who needs it: the badge, the credits and the note under
		// the metrics all describe the same answer, and asking three times would mean three calls to
		// the service to render one screen.
		$remote = AIPB_Remote::configured() ? AIPB_Remote::usage() : null;
		$status = self::service_status( $has_key, $remote );
		?>
		<div class="wrap aipb-settings">

			<div class="aipb-set-head">
				<h1>Aura — <?php esc_html_e( 'Settings', 'aipb' ); ?></h1>
				<span class="aipb-badge aipb-badge-<?php echo esc_attr( $status['tone'] ); ?><?php echo 'ok' === $status['tone'] ? ' is-pulsing' : ''; ?>">
					<?php echo esc_html( $status['label'] ); ?>
				</span>
			</div>

			<details class="aipb-note">
				<summary>
					<span class="aipb-note-icon" aria-hidden="true">i</span>
					<?php esc_html_e( 'What the service does — and what goes on working without it', 'aipb' ); ?>
					<?php self::icon_chevron( 'aipb-note-chev' ); ?>
				</summary>
				<p class="aipb-note-body">
					<?php echo wp_kses_post( __( 'Generation happens on the <b>Aura service</b>: this site sends it the instruction, its own design system and its own contents, and receives the section back. The pages already built <b>do not depend on the service</b> — they stay in the site and go on working even when it cannot be reached: the only thing that stops is generating new things.', 'aipb' ) ); ?>
				</p>
			</details>

			<?php self::usage_panel( $remote ); ?>

			<form method="post" action="options.php">
				<?php settings_fields( 'aipb' ); ?>

				<p class="aipb-section-label"><?php esc_html_e( 'Your account', 'aipb' ); ?></p>
				<div class="aipb-card">
					<?php self::account_field( $remote ); ?>

					<div class="aipb-field">
						<label class="aipb-label" for="aipb-key"><?php esc_html_e( 'Activation key', 'aipb' ); ?></label>

						<div class="aipb-row">
							<input type="password" id="aipb-key" class="aipb-key" name="<?php echo esc_attr( AIPB_Remote::OPT_KEY ); ?>"
								value="" autocomplete="off" spellcheck="false"
								placeholder="<?php echo $has_key ? esc_attr__( '•••••••••• key saved', 'aipb' ) : 'aura_xxxxxxxx_…'; ?>">

							<button type="button" class="aipb-btn" id="aipb-server-check"><?php esc_html_e( 'Check', 'aipb' ); ?></button>

							<?php if ( $has_key ) : ?>
								<input type="checkbox" class="aipb-clear" id="aipb-key-clear" name="aipb_server_key_clear" value="1">
								<label class="aipb-btn aipb-btn-danger" for="aipb-key-clear"><?php esc_html_e( 'Remove key', 'aipb' ); ?></label>
								<span class="aipb-clear-warn"><?php esc_html_e( 'It will be deleted when you save.', 'aipb' ); ?></span>
							<?php endif; ?>
						</div>

						<span id="aipb-server-status" class="aipb-check-out" role="status" aria-live="polite"></span>

						<p class="aipb-hint">
							<?php if ( $has_key ) : ?>
								<?php echo wp_kses_post( __( 'The key is saved and <b>encrypted</b>: for safety it is not shown again. Leave the field empty to keep it, or paste a new one to replace it.', 'aipb' ) ); ?>
							<?php else : ?>
								<?php echo wp_kses_post( __( 'You received it by email when you activated your plan, and you can find it again in your Aura dashboard. It is <b>encrypted</b> before being saved.', 'aipb' ) ); ?>
							<?php endif; ?>
						</p>
					</div>
				</div>

				<p class="aipb-section-label"><?php esc_html_e( 'Preferences', 'aipb' ); ?></p>
				<div class="aipb-card">
					<?php self::language_field(); ?>
				</div>

				<div class="aipb-savebar">
					<span class="aipb-savebar-note"><?php esc_html_e( 'Nothing on this page is saved until you say so.', 'aipb' ); ?></span>
					<button type="submit" class="aipb-btn aipb-btn-primary"><?php esc_html_e( 'Save Changes', 'aipb' ); ?></button>
				</div>
			</form>
		</div>
		<?php
	}

	/**
	 * The one line at the top that says whether any of this is working.
	 *
	 * It distinguishes the three states that get confused with one another: nothing configured (the
	 * plugin was packaged without an address, which the customer cannot fix), no key yet (which they
	 * can, right below), and a key that the service does not answer to.
	 *
	 * @param bool                    $has_key Is a key saved?
	 * @param array|WP_Error|null     $remote  The service's answer, already asked for once.
	 * @return array{tone:string,label:string}
	 */
	protected static function service_status( $has_key, $remote ) {
		if ( '' === AIPB_Remote::base_url() ) {
			return array( 'tone' => 'bad', 'label' => __( 'No service address', 'aipb' ) );
		}
		if ( ! $has_key ) {
			return array( 'tone' => 'idle', 'label' => __( 'Not activated', 'aipb' ) );
		}
		if ( is_wp_error( $remote ) ) {
			return array( 'tone' => 'bad', 'label' => __( 'Service unreachable', 'aipb' ) );
		}
		if ( is_array( $remote ) ) {
			return array( 'tone' => 'ok', 'label' => __( 'Service online', 'aipb' ) );
		}

		return array( 'tone' => 'idle', 'label' => __( 'Not checked', 'aipb' ) );
	}

	/**
	 * The interface language.
	 *
	 * The first entry is not a language: it is «whatever WordPress is speaking», which is what
	 * almost everybody wants and what a fresh install does without being asked. It names the
	 * language it currently resolves to, in brackets, because «follow WordPress» on its own tells
	 * you the rule and not the answer — and the question being asked here is the answer.
	 */
	protected static function language_field() {
		$available = AIPB_I18n::available();
		$stored    = AIPB_I18n::stored();
		$automatic = function_exists( 'determine_locale' ) ? (string) determine_locale() : (string) get_locale();
		$name      = isset( $available[ $automatic ] ) ? $available[ $automatic ] : $automatic;

		echo '<div class="aipb-field">';
		echo '<label class="aipb-label" for="aipb-locale">' . esc_html__( 'Interface language', 'aipb' ) . '</label>';

		echo '<span class="aipb-select-wrap">';
		self::icon_globe( 'aipb-globe' );
		echo '<select id="aipb-locale" class="aipb-select" name="' . esc_attr( AIPB_I18n::OPT_LOCALE ) . '">';

		printf(
			'<option value=""%s>%s</option>',
			selected( '', $stored, false ),
			/* translators: %s: the language WordPress is currently using, e.g. "Italiano". */
			esc_html( sprintf( __( 'Follow WordPress (%s)', 'aipb' ), $name ) )
		);

		foreach ( $available as $locale => $label ) {
			printf(
				'<option value="%s"%s>%s</option>',
				esc_attr( $locale ),
				selected( $locale, $stored, false ),
				esc_html( $label )
			);
		}

		echo '</select>';
		self::icon_chevron( 'aipb-chev' );
		echo '</span>';

		echo '<p class="aipb-hint">'
			. esc_html__( 'The language of Aura\'s own screens — the Studio, Brand, the content model and this page. It changes nothing else: the rest of the admin, the other plugins and the site itself go on speaking the language WordPress is set to.', 'aipb' )
			. '</p>';

		// A language WordPress can speak and this copy of the plugin cannot is worth saying out
		// loud: the alternative is a person who set their profile to German, sees an English Studio,
		// and reasonably concludes the setting is broken.
		if ( '' === $stored && ! isset( $available[ $automatic ] ) ) {
			echo '<p class="aipb-hint">'
				. esc_html( sprintf(
					/* translators: %s: a locale code, e.g. "de_DE". */
					__( 'Aura is not translated into %s yet, so its screens stay in English until it is.', 'aipb' ),
					$automatic
				) )
				. '</p>';
		}

		echo '</div>';
	}

	/**
	 * How much this site has spent.
	 *
	 * The service already reported the usage of every generation and the plugin threw it away. That
	 * was not a detail: without it, whoever uses the plugin has no way of knowing that a consistency
	 * pass over a twenty-section page costs twenty generations, and whoever sells it has no way of
	 * answering «how much have I used this month».
	 *
	 * Three figures and a log, in that order: the credits left is the one that decides whether you
	 * can work today, the rest is the explanation of where they went.
	 *
	 * @param array|WP_Error|null $remote The service's answer, asked for once by render().
	 */
	protected static function usage_panel( $remote ) {
		if ( ! class_exists( 'AIPB_Usage' ) ) {
			return;
		}

		$totals = AIPB_Usage::totals();

		echo '<p class="aipb-section-label">' . esc_html__( 'Usage', 'aipb' ) . '</p>';

		self::render_metrics( $remote, $totals );

		echo '<div class="aipb-card">';
		echo '<h2>' . esc_html__( 'Recent generations', 'aipb' ) . '</h2>';

		if ( ! $totals['count'] ) {
			echo '<p class="aipb-empty">' . esc_html__( 'No generation recorded on this site yet.', 'aipb' ) . '</p>';
			echo '</div>';

			return;
		}

		self::render_recent_table();

		echo '<p class="aipb-foot">' . esc_html__( 'The last twenty, kept on this site. What each one cost is counted in credits on your dashboard.', 'aipb' ) . '</p>';
		echo '</div>';
	}

	/**
	 * The three numbers.
	 *
	 * CREDITS, NOT TOKENS. The customer buys credits and is billed in credits: a token count is an
	 * implementation detail of whoever runs the model, and putting it on this page invites the one
	 * conversation nobody wants — «why did that section cost 4,318 of something». The same goes for
	 * the model's name: which model the service uses today is the service's business, and it changes
	 * without anybody's site having to know.
	 *
	 * @param array|WP_Error|null $remote
	 * @param array               $totals
	 */
	protected static function render_metrics( $remote, $totals ) {
		$has_remote = is_array( $remote );

		$used  = $has_remote ? self::credits( $remote, 'used_micro' ) : 0.0;
		$limit = $has_remote ? self::credits( $remote, 'credits_limit' ) : 0.0;
		$pct   = $limit > 0 ? min( 100, ( $used / $limit ) * 100 ) : 0;
		$tone  = $pct >= 90 ? ' is-bad' : ( $pct >= 70 ? ' is-warn' : '' );

		// The service counts the generations it billed for the whole account; this site counts the
		// ones it asked for. On an agency they are different numbers and the difference is the point:
		// the pool is shared, and this is the site's share of it.
		$generations = ( $has_remote && isset( $remote['generations'] ) ) ? (int) $remote['generations'] : (int) $totals['count'];

		echo '<div class="aipb-metrics">';

		/* Credits. */
		echo '<div class="aipb-metric">';
		echo '<span class="aipb-metric-label">' . esc_html__( 'Credits used', 'aipb' ) . '</span>';
		if ( $has_remote ) {
			echo '<span class="aipb-metric-value">' . esc_html( number_format_i18n( $used, 2 ) );
			if ( $limit > 0 ) {
				/* translators: %s: the number of credits included in the plan. */
				echo ' <small>' . esc_html( sprintf( __( 'of %s', 'aipb' ), number_format_i18n( $limit, 2 ) ) ) . '</small>';
			}
			echo '</span>';
			if ( $limit > 0 ) {
				echo '<div class="aipb-meter' . esc_attr( $tone ) . '"><i style="width:' . esc_attr( self::bar_width( $pct ) ) . '%"></i></div>';
				echo '<span class="aipb-metric-sub">' . esc_html( sprintf(
					/* translators: %s: the credits still available, e.g. "119.91". */
					__( '%s still available', 'aipb' ),
					number_format_i18n( max( 0, $limit - $used ), 2 )
				) ) . '</span>';
			}
		} else {
			echo '<span class="aipb-metric-value">—</span>';
			echo '<span class="aipb-metric-sub">' . esc_html__( 'The service is not answering with its figures.', 'aipb' ) . '</span>';
		}
		echo '</div>';

		/* Generations. */
		echo '<div class="aipb-metric">';
		echo '<span class="aipb-metric-label">' . esc_html__( 'Generations', 'aipb' ) . '</span>';
		echo '<span class="aipb-metric-value">' . esc_html( number_format_i18n( $generations ) ) . '</span>';
		echo '<span class="aipb-metric-sub">' . esc_html( sprintf(
			/* translators: %s: how many of those generations were asked for by this site. */
			__( '%s from this site', 'aipb' ),
			number_format_i18n( (int) $totals['count'] )
		) ) . '</span>';
		echo '</div>';

		/* The plan. */
		echo '<div class="aipb-metric">';
		echo '<span class="aipb-metric-label">' . esc_html__( 'Plan', 'aipb' ) . '</span>';
		if ( $has_remote ) {
			echo '<span class="aipb-metric-value is-text">' . esc_html( self::plan_name( $remote ) ) . '</span>';

			// The bonus credits (a trial, a gift) are a separate pool that does not reset with the
			// month: leaving them out would understate what is actually left to spend.
			$bonus = isset( $remote['grant_remaining'] ) ? (int) $remote['grant_remaining'] / max( 1, (int) ( isset( $remote['micro_per_credit'] ) ? $remote['micro_per_credit'] : 1000000 ) ) : 0;
			if ( $bonus > 0 ) {
				echo '<span class="aipb-metric-sub">' . esc_html( sprintf(
					/* translators: %s: bonus credits left, e.g. "10.00". */
					__( 'plus %s bonus credits', 'aipb' ),
					number_format_i18n( $bonus, 2 )
				) ) . '</span>';
			} elseif ( $limit > 0 ) {
				echo '<span class="aipb-metric-sub">' . esc_html( sprintf(
					/* translators: %s: the credits the plan includes each month. */
					__( '%s credits a month', 'aipb' ),
					number_format_i18n( $limit, 2 )
				) ) . '</span>';
			}
		} else {
			echo '<span class="aipb-metric-value is-text">—</span>';
			echo '<span class="aipb-metric-sub">' . esc_html__( 'Readable only when the service answers.', 'aipb' ) . '</span>';
		}
		echo '</div>';

		echo '</div>';

		/* The month everything above refers to, and the reason it might be missing. */
		$month = $has_remote && isset( $remote['month'] ) ? (string) $remote['month'] : (string) $totals['month'];
		echo '<p class="aipb-foot">' . esc_html( sprintf(
			/* translators: %s: a month, e.g. "2026-08". */
			__( 'Figures for month %s, kept by the Aura service.', 'aipb' ),
			$month
		) );
		if ( is_wp_error( $remote ) && AIPB_Remote::configured() ) {
			/* translators: %s: the error message. */
			echo ' ' . esc_html( sprintf( __( 'Service usage unavailable: %s', 'aipb' ), $remote->get_error_message() ) );
		}
		echo '</p>';
	}

	/**
	 * Who this site is, to the service — and the way out to the place where it can be changed.
	 *
	 * The account id is a fingerprint of the key, not the key: it can be read out over a support
	 * call without handing anybody the licence. It is the first thing the other end asks for, which
	 * is why it sits here and why it copies with one click.
	 *
	 * Everything that is actually MANAGED — the plan, the invoices, the sites, a new key — lives on
	 * the dashboard. This page does not try to be it: it says where it is.
	 *
	 * @param array|WP_Error|null $remote
	 */
	protected static function account_field( $remote ) {
		$id   = is_array( $remote ) && isset( $remote['account'] ) ? (string) $remote['account'] : '';
		$home = AIPB_Remote::base_url();

		echo '<div class="aipb-field">';
		echo '<span class="aipb-label">' . esc_html__( 'Account', 'aipb' ) . '</span>';

		echo '<div class="aipb-row">';

		if ( '' !== $id ) {
			echo '<button type="button" class="aipb-req" data-req="' . esc_attr( $id ) . '" title="' . esc_attr__( 'Copy the account id', 'aipb' ) . '">'
				. '<span>' . esc_html( $id ) . '</span>';
			self::icon_copy();
			echo '</button>';
		}

		if ( is_array( $remote ) ) {
			echo '<span class="aipb-badge aipb-badge-idle aipb-badge-plain">' . esc_html( sprintf(
				/* translators: %s: the plan's name, e.g. "Solo". */
				__( '%s plan', 'aipb' ),
				self::plan_name( $remote )
			) ) . '</span>';
		}

		if ( '' !== $home ) {
			echo '<a class="aipb-btn aipb-btn-ghost" href="' . esc_url( $home . '/app' ) . '" target="_blank" rel="noopener noreferrer">'
				. esc_html__( 'Open the Aura dashboard', 'aipb' );
			self::icon_external();
			echo '</a>';
		}

		echo '</div>';

		echo '<p class="aipb-hint">' . esc_html__( 'Your plan, your invoices, the sites on your licence and a new key are all managed on the dashboard. The id above is what support asks for: it identifies the account without revealing the key.', 'aipb' ) . '</p>';
		echo '</div>';
	}

	/** The plan's name as the customer bought it, from the slug the service stores. */
	protected static function plan_name( $remote ) {
		$plan = isset( $remote['plan'] ) ? (string) $remote['plan'] : '';

		return '' === $plan ? __( 'No plan', 'aipb' ) : ucfirst( $plan );
	}

	/**
	 * How wide the bar is drawn.
	 *
	 * Nothing spent draws nothing: a minimum width would turn «you have used none of it» into a
	 * visible sliver, and telling those two apart is the only job the bar has. Everything else gets
	 * at least one percent, so a real but tiny consumption does not vanish either.
	 */
	protected static function bar_width( $pct ) {
		return $pct > 0 ? max( 1, round( $pct ) ) : 0;
	}

	/** Micro-credits as the service reports them, turned into credits. */
	protected static function credits( $remote, $key ) {
		$per_credit = isset( $remote['micro_per_credit'] ) ? max( 1, (int) $remote['micro_per_credit'] ) : 1000000;

		return isset( $remote[ $key ] ) ? (int) $remote[ $key ] / $per_credit : 0.0;
	}

	/**
	 * The last generations asked for by this site.
	 *
	 * WHAT IS NOT HERE: the model and the token counts. Which model the service used is the
	 * service's business — it changes for everybody at once, and a name on this page would turn
	 * every change into a support question — and tokens are the unit the model is billed in, not the
	 * unit the customer bought. What is left is what somebody actually reads this table for: what
	 * was made, when, by whom, and the id to quote if it went wrong.
	 */
	protected static function render_recent_table() {
		$rows = AIPB_Usage::recent();

		// One query for every author in the table instead of one per row.
		$authors = array_filter( array_map( function ( $row ) {
			return isset( $row['user'] ) ? (int) $row['user'] : 0;
		}, $rows ) );
		if ( $authors ) {
			cache_users( array_unique( $authors ) );
		}

		echo '<div class="aipb-log-wrap"><table class="aipb-log"><thead><tr>'
			. '<th>' . esc_html__( 'When', 'aipb' ) . '</th>'
			. '<th>' . esc_html__( 'What', 'aipb' ) . '</th>'
			. '<th>' . esc_html__( 'By', 'aipb' ) . '</th>'
			. '<th>' . esc_html__( 'Request id', 'aipb' ) . '</th>'
			. '</tr></thead><tbody>';

		foreach ( $rows as $row ) {
			$when = isset( $row['ts'] ) ? (int) $row['ts'] : 0;
			$mode = isset( $row['mode'] ) ? (string) $row['mode'] : '';
			$req  = isset( $row['req'] ) ? (string) $row['req'] : '';

			echo '<tr>'
				. '<td class="aipb-when">' . esc_html( $when ? wp_date( 'd/m/Y H:i', $when ) : '—' ) . '</td>'
				. '<td><span class="aipb-kind aipb-kind-' . esc_attr( sanitize_html_class( $mode ) ) . '">' . esc_html( self::mode_label( $mode ) ) . '</span></td>'
				. '<td class="aipb-who">' . esc_html( self::author( isset( $row['user'] ) ? (int) $row['user'] : 0 ) ) . '</td>'
				. '<td>';

			if ( '' !== $req ) {
				echo '<button type="button" class="aipb-req" data-req="' . esc_attr( $req ) . '" title="' . esc_attr__( 'Copy the request id', 'aipb' ) . '">'
					. '<span>' . esc_html( $req ) . '</span>';
				self::icon_copy();
				echo '</button>';
			} else {
				echo '<span class="aipb-who">—</span>';
			}

			echo '</td></tr>';
		}

		echo '</tbody></table></div>';
	}

	/**
	 * What a generation was, in the words of whoever asked for it.
	 *
	 * The stored value is the engine's own vocabulary — `patch`, `tokens`, `global`. Those are the
	 * right names inside the code and the wrong ones on a screen bought by somebody who has never
	 * read it. An unknown mode falls through as itself: a new one added on the service must not
	 * empty a column.
	 */
	protected static function mode_label( $mode ) {
		$labels = array(
			'section'   => __( 'New section', 'aipb' ),
			'patch'     => __( 'Edit', 'aipb' ),
			'global'    => __( 'Whole page', 'aipb' ),
			'plan'      => __( 'Page plan', 'aipb' ),
			'direction' => __( 'Art direction', 'aipb' ),
			'tokens'    => __( 'Design tokens', 'aipb' ),
			'model'     => __( 'Content model', 'aipb' ),
		);

		return isset( $labels[ $mode ] ) ? $labels[ $mode ] : $mode;
	}

	/** Who asked for it. A user deleted since is not an error, just a name nobody can give back. */
	protected static function author( $user_id ) {
		if ( ! $user_id ) {
			return '—';
		}

		$user = get_userdata( $user_id );

		return $user ? $user->display_name : '—';
	}

	/* ----------------------------------- Icons -------------------------------- */

	protected static function icon_chevron( $class = '' ) {
		echo '<svg class="' . esc_attr( $class ) . '" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true" focusable="false">'
			. '<path d="M3 4.5 6 7.5 9 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
	}

	protected static function icon_globe( $class = '' ) {
		echo '<svg class="' . esc_attr( $class ) . '" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">'
			. '<circle cx="8" cy="8" r="6.2" stroke="currentColor" stroke-width="1.3"/>'
			. '<path d="M1.8 8h12.4M8 1.8c1.6 1.7 2.4 3.8 2.4 6.2s-.8 4.5-2.4 6.2c-1.6-1.7-2.4-3.8-2.4-6.2s.8-4.5 2.4-6.2Z" stroke="currentColor" stroke-width="1.3"/></svg>';
	}

	protected static function icon_external() {
		echo '<svg width="12" height="12" viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false">'
			. '<path d="M5.6 2.6H2.9c-.8 0-1.4.6-1.4 1.4v7.1c0 .8.6 1.4 1.4 1.4H10c.8 0 1.4-.6 1.4-1.4V8.4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>'
			. '<path d="M8.2 1.6h4.2v4.2M12.4 1.6 6.6 7.4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
	}

	protected static function icon_copy() {
		echo '<svg width="12" height="12" viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false">'
			. '<rect x="4.7" y="4.7" width="8" height="8" rx="1.8" stroke="currentColor" stroke-width="1.3"/>'
			. '<path d="M9.6 2.3H3.1c-.9 0-1.6.7-1.6 1.6v6.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
	}

	/* ---------------------------- The check button ---------------------------- */

	/**
	 * Checking the service from the button in the settings.
	 *
	 * /health is open and does not require the licence: this separates the two cases that usually
	 * get confused — "I cannot reach it" (wrong address, DNS, firewall) from "I reach it but the
	 * licence is not accepted". A licence can only be verified by using it, so we try with a request
	 * that does NOT call the model: /v1/prompt/preview authenticates like /v1/generate but costs no
	 * token.
	 */
	public static function ajax_server_health() {
		check_ajax_referer( 'aipb_server_health' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Permission denied.', 'aipb' ) ), 403 );
		}

		// With no address the plugin was packaged badly: it is not something the customer can fix
		// from here, and telling them «configure the address» would send them looking for a field
		// that does not exist.
		if ( '' === AIPB_Remote::base_url() ) {
			wp_send_json_error( array( 'message' => __( 'This copy of the plugin has no service address: download it again from your Aura dashboard.', 'aipb' ) ) );
		}

		// THE KEY TO TRY IS THE ONE IN THE FIELD, when there is one. The natural gesture is to paste
		// and press Check: checking the already saved one instead would answer about the wrong key —
		// «rejected» to someone who has just pasted the right one, or «valid» to someone who has
		// pasted a wrong one. The field is empty when the saved one is to be rechecked.
		$typed = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['key'] ) ) : '';
		$key   = '' !== $typed ? $typed : AIPB_Remote::key();

		$health = AIPB_Remote::health( true );
		if ( ! $health ) {
			wp_send_json_error( array( 'message' => __( 'Service unreachable. Try again shortly: the pages already built go on working.', 'aipb' ) ) );
		}

		$dsl = isset( $health['dsl'] ) ? (int) $health['dsl'] : 0;

		if ( '' === $key ) {
			wp_send_json_error( array(
				'message' => __( 'The service is reachable, but the activation key is missing.', 'aipb' ),
			) );
		}

		$probe = wp_remote_post( AIPB_Remote::base_url() . '/v1/prompt/preview', array(
			'timeout' => 15,
			'headers' => array(
				'Authorization' => 'Bearer ' . $key,
				'Content-Type'  => 'application/json',
			),
			'body'    => wp_json_encode( array( 'mode' => 'section', 'prompt' => 'check', 'site' => array() ) ),
		) );

		if ( is_wp_error( $probe ) ) {
			wp_send_json_error( array( 'message' => $probe->get_error_message() ) );
		}
		if ( 200 !== (int) wp_remote_retrieve_response_code( $probe ) ) {
			wp_send_json_error( array( 'message' => __( 'Activation key rejected by the service.', 'aipb' ) ) );
		}

		// The service's DSL being newer than the engine's is not an error — the service never teaches
		// a directive we do not declare — but it is the moment to update. The VERSIONS themselves
		// stay out of the message: «prompts 7.1, DSL 12» is the engine talking to itself, and the
		// only thing the person pressing the button asked was whether it works.
		$note = ( $dsl > AIPB_Template_Engine::DSL )
			? ' ' . __( 'The service supports newer features: update the plugin to use them.', 'aipb' )
			: '';

		wp_send_json_success( array(
			'message' => __( '✓ Service reachable, key valid.', 'aipb' ) . $note,
		) );
	}
}
