<?php
/**
 * Lightweight Human Design client — admin UI + AJAX.
 *
 * Stores a local roster of people and pulls their Bodygraph chart + readings
 * from a Ray Bogman HD Suite install via the subscription proxy API. Fetched
 * content is cached locally (chart + readings) so it persists between visits;
 * a Refresh button re-pulls from the API. Only the content the subscription
 * token shares is shown.
 *
 * @package Reloom\HumanDesign
 */

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

/**
 * Admin pages, profile storage and AJAX for the lightweight client.
 */
class RBHDC_Plugin {

	const MENU_SLUG       = 'rbhdc';
	const SETTINGS_SLUG   = 'rbhdc-settings';
	const PROFILES_OPTION = 'rbhdc_profiles';
	const NONCE           = 'rbhdc_ajax';

	/**
	 * Scope => label. 'chart' is the Bodygraph chart; the rest are readings.
	 *
	 * @return array<string,string>
	 */
	public static function scope_labels() {
		return array(
			'chart'    => __( 'Chart', 'reloom-human-design' ),
			'quick'    => __( 'Quick reading', 'reloom-human-design' ),
			'channels' => __( 'Channels', 'reloom-human-design' ),
			'centers'  => __( 'Centers', 'reloom-human-design' ),
			'full'     => __( 'Full reading', 'reloom-human-design' ),
			'profile'  => __( 'Profile', 'reloom-human-design' ),
			'gates'    => __( 'Gates', 'reloom-human-design' ),
			'health'   => __( 'Health', 'reloom-human-design' ),
			'sport'    => __( 'Sport', 'reloom-human-design' ),
			'sleep'    => __( 'Sleep', 'reloom-human-design' ),
			'career'   => __( 'Career', 'reloom-human-design' ),
			'lifefit'  => __( 'Life-fit', 'reloom-human-design' ),
			'nervous'  => __( 'Nervous-system regulation', 'reloom-human-design' ),
			'grief'    => __( 'Grief & life transitions', 'reloom-human-design' ),
		);
	}

	/** Reading slot keys in display order (everything in scope_labels except chart). */
	public static function reading_slots() {
		$keys = array_keys( self::scope_labels() );
		return array_values( array_filter( $keys, static function ( $k ) {
			return 'chart' !== $k;
		} ) );
	}

	/**
	 * Wire hooks.
	 */
	public static function init() {
		add_action( 'admin_menu', array( __CLASS__, 'menu' ) );
		add_action( 'admin_init', array( __CLASS__, 'maybe_migrate_legacy' ) );
		add_action( 'admin_init', array( __CLASS__, 'maybe_flush_prevoice_readings' ) );
		add_action( 'admin_init', array( __CLASS__, 'maybe_handle_connect' ) );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) );
		add_action( 'wp_ajax_rbhdc_save_settings', array( __CLASS__, 'ajax_save_settings' ) );
		add_action( 'wp_ajax_rbhdc_test', array( __CLASS__, 'ajax_test' ) );
		add_action( 'wp_ajax_rbhdc_save_profile', array( __CLASS__, 'ajax_save_profile' ) );
		add_action( 'wp_ajax_rbhdc_delete_profile', array( __CLASS__, 'ajax_delete_profile' ) );
		add_action( 'wp_ajax_rbhdc_content', array( __CLASS__, 'ajax_content' ) );
		add_action( 'wp_ajax_rbhdc_pdf', array( __CLASS__, 'ajax_pdf' ) );
		add_action( 'wp_ajax_rbhdc_export', array( __CLASS__, 'ajax_export' ) );
		add_action( 'wp_ajax_rbhdc_import', array( __CLASS__, 'ajax_import' ) );
		add_action( 'wp_ajax_rbhdc_locations', array( __CLASS__, 'ajax_locations' ) );
	}

	/* --------------------------------------------------------------------- */
	/* Profile + content storage                                              */
	/* --------------------------------------------------------------------- */

	/** @return array<int,array> */
	public static function get_all() {
		$rows = get_option( self::PROFILES_OPTION, array() );
		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * @param string $id Profile id.
	 * @return array|null
	 */
	public static function find( $id ) {
		foreach ( self::get_all() as $row ) {
			if ( isset( $row['id'] ) && (string) $row['id'] === (string) $id ) {
				return $row;
			}
		}
		return null;
	}

	/**
	 * Insert/update from raw input.
	 *
	 * @param array $in Input.
	 * @return array Saved row.
	 */
	/**
	 * Sanitize a raw profile array (from $_POST or an imported JSON file) —
	 * allowlist of known fields, each through the matching sanitizer. save()
	 * validates formats (date/time/gender) on top of this.
	 *
	 * @param array $raw Untrusted input.
	 * @return array Clean profile fields.
	 */
	private static function sanitize_profile_input( array $raw ) {
		return array(
			'id'         => sanitize_text_field( (string) ( $raw['id'] ?? '' ) ),
			'first_name' => sanitize_text_field( (string) ( $raw['first_name'] ?? '' ) ),
			'last_name'  => sanitize_text_field( (string) ( $raw['last_name'] ?? '' ) ),
			'gender'     => sanitize_key( (string) ( $raw['gender'] ?? '' ) ),
			'date'       => sanitize_text_field( (string) ( $raw['date'] ?? '' ) ),
			'time'       => sanitize_text_field( (string) ( $raw['time'] ?? '' ) ),
			'place'      => sanitize_text_field( (string) ( $raw['place'] ?? '' ) ),
			'timezone'   => sanitize_text_field( (string) ( $raw['timezone'] ?? '' ) ),
			'email'      => sanitize_email( (string) ( $raw['email'] ?? '' ) ),
			'notes'      => sanitize_textarea_field( (string) ( $raw['notes'] ?? '' ) ),
		);
	}

	public static function save( array $in ) {
		$rows  = self::get_all();
		$id    = isset( $in['id'] ) ? preg_replace( '/[^a-f0-9]/', '', (string) $in['id'] ) : '';
		$first = sanitize_text_field( $in['first_name'] ?? '' );
		$last  = sanitize_text_field( $in['last_name'] ?? '' );
		$row   = array(
			'first_name' => $first,
			'last_name'  => $last,
			'name'       => trim( $first . ' ' . $last ),
			'gender'     => in_array( ( $in['gender'] ?? '' ), array( 'male', 'female' ), true ) ? $in['gender'] : '',
			'date'       => preg_match( '/^\d{4}-\d{2}-\d{2}$/', ( $in['date'] ?? '' ) ) ? $in['date'] : '',
			'time'       => preg_match( '/^\d{2}:\d{2}/', ( $in['time'] ?? '' ) ) ? substr( $in['time'], 0, 5 ) : '',
			'place'      => sanitize_text_field( $in['place'] ?? '' ),
			'timezone'   => sanitize_text_field( $in['timezone'] ?? '' ),
			'email'      => sanitize_email( $in['email'] ?? '' ),
			'notes'      => sanitize_textarea_field( $in['notes'] ?? '' ),
		);
		if ( '' === $row['name'] ) {
			$row['name'] = __( '(unnamed)', 'reloom-human-design' );
		}
		if ( '' !== $id ) {
			foreach ( $rows as $i => $existing ) {
				if ( isset( $existing['id'] ) && (string) $existing['id'] === $id ) {
					$row['id']      = $id;
					$row['created'] = $existing['created'] ?? time();
					// Carry over link/sync metadata so an edit doesn't unlink the row
					// from its Reloom profile (which would let it be re-created).
					foreach ( array( 'remote_id', 'is_self', 'synced', 'synced_at' ) as $meta ) {
						if ( isset( $existing[ $meta ] ) ) {
							$row[ $meta ] = $existing[ $meta ];
						}
					}
					// Birth change invalidates any cached content.
					if ( ( $existing['date'] ?? '' ) !== $row['date'] || ( $existing['time'] ?? '' ) !== $row['time']
						|| ( $existing['place'] ?? '' ) !== $row['place'] || ( $existing['timezone'] ?? '' ) !== $row['timezone'] ) {
						self::clear_content( $id );
					}
					$rows[ $i ] = $row;
					update_option( self::PROFILES_OPTION, $rows, false );
					return $row;
				}
			}
		}
		$row['id']      = bin2hex( random_bytes( 8 ) );
		$row['created'] = time();
		$rows[]         = $row;
		update_option( self::PROFILES_OPTION, $rows, false );
		return $row;
	}

	/**
	 * @param string $id Profile id.
	 * @return bool
	 */
	public static function delete( $id ) {
		$rows = self::get_all();
		$out  = array();
		$hit  = false;
		foreach ( $rows as $row ) {
			if ( isset( $row['id'] ) && (string) $row['id'] === (string) $id ) {
				$hit = true;
				continue;
			}
			$out[] = $row;
		}
		if ( $hit ) {
			update_option( self::PROFILES_OPTION, $out, false );
			self::clear_content( $id );
		}
		return $hit;
	}

	/** Stored chart for a profile: array{data:array,at:int}|null. */
	public static function get_chart( $id ) {
		$o = get_option( 'rbhdc_chart_' . $id, null );
		return ( is_array( $o ) && isset( $o['data'] ) ) ? $o : null;
	}
	public static function store_chart( $id, array $chart ) {
		update_option( 'rbhdc_chart_' . $id, array( 'data' => $chart, 'at' => time() ), false );
	}

	/** Stored readings for a profile: array<slot, array{text:string,at:int}>. */
	public static function get_readings( $id ) {
		$o = get_option( 'rbhdc_readings_' . $id, array() );
		return is_array( $o ) ? $o : array();
	}
	public static function store_reading( $id, $slot, $text ) {
		$o          = self::get_readings( $id );
		$o[ $slot ] = array( 'text' => (string) $text, 'at' => time() );
		update_option( 'rbhdc_readings_' . $id, $o, false );
	}

	/** Drop all cached content for a profile. */
	public static function clear_content( $id ) {
		delete_option( 'rbhdc_chart_' . $id );
		delete_option( 'rbhdc_readings_' . $id );
	}

	/* --------------------------------------------------------------------- */
	/* Menu + assets                                                          */
	/* --------------------------------------------------------------------- */

	public static function menu() {
		add_menu_page(
			__( 'Human Design', 'reloom-human-design' ),
			__( 'Human Design', 'reloom-human-design' ),
			'manage_options',
			self::MENU_SLUG,
			array( __CLASS__, 'render_profiles_page' ),
			'dashicons-chart-pie',
			58
		);
		add_submenu_page( self::MENU_SLUG, __( 'Profiles', 'reloom-human-design' ), __( 'Profiles', 'reloom-human-design' ), 'manage_options', self::MENU_SLUG, array( __CLASS__, 'render_profiles_page' ) );
		add_submenu_page( self::MENU_SLUG, __( 'Settings', 'reloom-human-design' ), __( 'Settings', 'reloom-human-design' ), 'manage_options', self::SETTINGS_SLUG, array( __CLASS__, 'render_settings_page' ) );
		add_submenu_page( self::MENU_SLUG, __( 'About', 'reloom-human-design' ), __( 'About', 'reloom-human-design' ), 'manage_options', self::MENU_SLUG . '-about', array( __CLASS__, 'render_about_page' ) );
		add_submenu_page( self::MENU_SLUG, __( 'FAQ', 'reloom-human-design' ), __( 'FAQ', 'reloom-human-design' ), 'manage_options', self::MENU_SLUG . '-faq', array( __CLASS__, 'render_faq_page' ) );
	}

	/** Brand teal used for the section icons. */
	const BRAND = '#2c8a7d';

	/**
	 * About page — the Reloom.life story, the author, links, and how to get more
	 * from the plugin. Static, admin-only content.
	 */
	public static function render_about_page() {
		$teal = self::BRAND;
		?>
		<div class="wrap rbhd-wrap">
			<h1>🌿 <?php esc_html_e( 'About Reloom', 'reloom-human-design' ); ?></h1>

			<div class="rbhd-card" style="padding:18px 24px;max-width:860px;margin:14px 0;">
				<h2 style="margin-top:0;">
					<span class="dashicons dashicons-share-alt" style="font-size:24px;width:24px;height:24px;color:<?php echo esc_attr( $teal ); ?>;margin-right:8px;vertical-align:middle;"></span>
					<?php esc_html_e( 'The Reloom story', 'reloom-human-design' ); ?>
				</h2>
				<p style="font-size:14px;line-height:1.7;">
					<strong>Reloom</strong> began with a simple question: <em>how are we wired to one another?</em> Human Design maps each person as a bodygraph — a chart of defined centres, channels and gates cast from their moment of birth. Where two charts meet, some channels light up between them: one person carries a gate, the other completes it. That is the thread. <strong>Re-loom</strong> is the act of weaving those threads back together — seeing connection and compatibility as the heart of the system, not an afterthought.
				</p>
				<p style="font-size:14px;line-height:1.7;">
					<a href="https://reloom.life" target="_blank" rel="noopener">reloom.life</a> is the platform behind this plugin. It computes every chart, ranks the strongest connections across a roster <em>offline</em> (no AI needed to know who is wired to whom), and only then calls AI for the readings — always in two voices: a <strong>Plain</strong>, jargon-free version by default, and a deeper <strong>Human&nbsp;Design</strong> version a click away. This WordPress plugin brings those same charts and readings straight into your site through Reloom's secure proxy — your Reloom plan decides what's available, and there is nothing else to configure.
				</p>
				<p style="font-size:13px;line-height:1.6;color:#646970;font-style:italic;">
					Human Design is a synthesis system; Reloom is an independent product and is not affiliated with any Human Design organisation or trademark holder.
				</p>

				<h3><?php esc_html_e( 'What this plugin gives you', 'reloom-human-design' ); ?></h3>
				<table class="widefat striped" style="max-width:640px;">
					<tbody>
						<tr><td>Bodygraph chart, rendered natively in WordPress</td><td style="text-align:center;"><span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span></td></tr>
						<tr><td>AI readings — Quick, Channels, Centers, Full, Career &amp; more</td><td style="text-align:center;"><span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span></td></tr>
						<tr><td>Plain ↔ Human Design reading voices (per reading)</td><td style="text-align:center;"><span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span></td></tr>
						<tr><td>Resizable split view — reading left, bodygraph pinned right</td><td style="text-align:center;"><span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span></td></tr>
						<tr><td>One-click Connect to your Reloom account (no tokens to paste)</td><td style="text-align:center;"><span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span></td></tr>
						<tr><td>Local roster with cached charts &amp; readings, export/import</td><td style="text-align:center;"><span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span></td></tr>
						<tr><td>Download a polished PDF of any profile</td><td style="text-align:center;"><span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span></td></tr>
						<tr><td>Plan usage at a glance — "x/y profiles" pill on the Profiles page</td><td style="text-align:center;"><span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span></td></tr>
					</tbody>
				</table>
			</div>

			<div class="rbhd-card" style="padding:18px 24px;max-width:860px;margin:14px 0;">
				<h2 style="margin-top:0;">
					<span class="dashicons dashicons-admin-users" style="font-size:24px;width:24px;height:24px;color:<?php echo esc_attr( $teal ); ?>;margin-right:8px;vertical-align:middle;"></span>
					<?php esc_html_e( 'About the author', 'reloom-human-design' ); ?>
				</h2>
				<p style="font-size:14px;line-height:1.7;">
					<strong>Ray Bogman</strong> — Fractional CTO, AI innovator and builder, based in the Netherlands. Reloom is his exploration of what happens when a decades-deep engineering practice meets Human Design: rigorous, offline-first connection math under a calm, human-readable surface. He designs, builds and ships the whole stack — from the bodygraph renderer to this plugin.
				</p>
				<p style="font-size:14px;line-height:1.7;">
					<a href="https://reloom.life" target="_blank" rel="noopener" class="button button-primary" style="margin:2px 8px 2px 0;">
						<span class="dashicons dashicons-heart" style="font-size:16px;width:16px;height:16px;vertical-align:middle;margin-right:4px;"></span>reloom.life
					</a>
					<a href="https://raybogman.com" target="_blank" rel="noopener" class="button button-secondary" style="margin:2px 8px 2px 0;">
						<span class="dashicons dashicons-admin-links" style="font-size:16px;width:16px;height:16px;vertical-align:middle;margin-right:4px;"></span>raybogman.com
					</a>
					<a href="https://www.linkedin.com/in/raybogman" target="_blank" rel="noopener" class="button button-secondary" style="margin:2px 8px 2px 0;">
						<span class="dashicons dashicons-linkedin" style="font-size:16px;width:16px;height:16px;vertical-align:middle;margin-right:4px;"></span>LinkedIn
					</a>
					<a href="https://github.com/raybogman" target="_blank" rel="noopener" class="button button-secondary" style="margin:2px 8px 2px 0;">
						<span class="dashicons dashicons-github" style="font-size:16px;width:16px;height:16px;vertical-align:middle;margin-right:4px;"></span>GitHub
					</a>
				</p>
			</div>

			<div class="rbhd-card" style="padding:18px 24px;max-width:860px;margin:14px 0;">
				<h2 style="margin-top:0;">
					<span class="dashicons dashicons-megaphone" style="font-size:24px;width:24px;height:24px;color:<?php echo esc_attr( $teal ); ?>;margin-right:8px;vertical-align:middle;"></span>
					<?php esc_html_e( 'Get the most from Reloom', 'reloom-human-design' ); ?>
				</h2>
				<ul style="font-size:14px;line-height:1.8;list-style:disc;margin-left:20px;">
					<li><strong>Publish charts to your site.</strong> Embed profiles on client or member pages so readers get their bodygraph and readings without leaving your WordPress site.</li>
					<li><strong>Lead with connection.</strong> Reloom's edge is compatibility — pair two people and show the channels wired between them. Use it for coaching, teams, dating, or family readings.</li>
					<li><strong>Offer both voices.</strong> Keep readings in <em>Plain</em> for newcomers and let power users flip to <em>Human Design</em> — one profile serves both audiences.</li>
					<li><strong>Upgrade for more.</strong> Your Reloom plan unlocks additional reading types and compatibility features. See <a href="https://reloom.life/pricing" target="_blank" rel="noopener">reloom.life/pricing</a>.</li>
					<li><strong>Read the Journal.</strong> Guides and Human Design deep-dives live at <a href="https://reloom.life/journal" target="_blank" rel="noopener">reloom.life/journal</a> — great to link from your own posts.</li>
					<li><strong>Spread the word.</strong> If Reloom helps you, a mention or a link back to <a href="https://reloom.life" target="_blank" rel="noopener">reloom.life</a> genuinely helps the project grow. 🌿</li>
				</ul>
			</div>
		</div>
		<?php
	}

	/** FAQ page — common questions about the plugin and how it works with Reloom. */
	public static function render_faq_page() {
		$teal = self::BRAND;
		$qas  = array(
			array( 'What does this plugin do?', 'It connects your WordPress site to your <a href="https://reloom.life" target="_blank" rel="noopener">reloom.life</a> account and pulls Human Design bodygraph charts and AI readings for the people (profiles) you add. Charts and readings are cached locally so they load instantly and persist between visits.' ),
			array( 'Do I need a Reloom account?', 'Yes. The plugin is a client for Reloom — it has no AI or chart engine of its own. Create an account at <a href="https://reloom.life" target="_blank" rel="noopener">reloom.life</a>, then use the one-click Connect on the Settings page. Your Reloom plan decides which charts and readings are available.' ),
			array( 'How does connecting work — do I paste an API key?', 'No keys to copy. The Connect button uses a secure OAuth-style flow (PKCE): you approve the connection inside Reloom and are redirected back. You can also paste an API URL + token manually if you prefer.' ),
			array( 'What is a bodygraph?', 'The bodygraph is the core Human Design chart — nine centres joined by channels and 64 gates, calculated from a person\'s birth date, time and place. Reloom renders it natively in WordPress and it stays pinned on the right while you read.' ),
			array( 'What are the Plain and Human Design reading voices?', 'Every reading comes in two voices. <strong>Plain</strong> (the default) is warm, jargon-free language anyone can follow. <strong>Human Design</strong> uses the full terminology — types, authorities, profiles, channels. Toggle per reading; each voice is cached separately, so switching back is instant.' ),
			array( 'How many profiles can I create?', 'That depends on your Reloom plan. The pill next to the Profiles page title shows your usage — e.g. <strong>Practitioner · 8/25 profiles</strong>: the profiles in this roster against your plan\'s limit. It turns red when the plan is full; upgrade in the Reloom dashboard for more. The same numbers appear on the Settings page.' ),
			array( 'Why is the reading on the left and the chart on the right?', 'That mirrors reloom.life: you read the interpretation on the left and glance at the bodygraph pinned on the right. Drag the divider in the middle to give either side more room — your split is remembered.' ),
			array( 'What reading types are available?', 'Depending on your plan: a Quick reading, Channels, Centers, a Full reading, Profile, Gates, and life-area readings such as Health, Sport, Sleep and Career. Anything not in your plan simply won\'t appear.' ),
			array( 'How long does a reading take?', 'Charts return in a second or two. AI readings are generated on Reloom\'s servers and typically take 10–30 seconds the first time. After that they\'re cached — instant on every later visit until you press Refresh.' ),
			array( 'Where is my data stored?', 'Profiles you add, plus their cached charts and readings, are stored in your own WordPress database. Deleting a profile here removes the local copy only — your Reloom account is untouched. Birth details are sent to Reloom to compute charts and readings.' ),
			array( 'Can I export a profile as a PDF?', 'Yes. Open a profile and click <strong>Download PDF</strong> for a polished document with the bodygraph and readings — handy for clients or your own records.' ),
			array( 'Can I move my roster between sites?', 'Yes. The Profiles page has Export and Import so you can back up your roster or move it to another site running the plugin.' ),
			array( 'Is this affiliated with any Human Design organisation?', 'No. Reloom is an independent product built by Ray Bogman. Human Design terms belong to their respective owners; this plugin is an independent integration.' ),
		);
		?>
		<div class="wrap rbhd-wrap">
			<h1>🌿 <?php esc_html_e( 'Reloom — Frequently Asked Questions', 'reloom-human-design' ); ?></h1>

			<div class="rbhd-card" style="padding:16px 24px;max-width:860px;margin:14px 0;">
				<h2 style="margin-top:0;">
					<span class="dashicons dashicons-editor-help" style="font-size:24px;width:24px;height:24px;color:<?php echo esc_attr( $teal ); ?>;margin-right:8px;vertical-align:middle;"></span>
					<?php esc_html_e( 'Questions', 'reloom-human-design' ); ?>
				</h2>
				<ol style="line-height:1.9;">
					<?php foreach ( $qas as $i => $qa ) : ?>
						<li><a href="#rbhdc-faq-<?php echo (int) $i; ?>"><?php echo esc_html( $qa[0] ); ?></a></li>
					<?php endforeach; ?>
				</ol>
			</div>

			<?php foreach ( $qas as $i => $qa ) : ?>
				<div class="rbhd-card" id="rbhdc-faq-<?php echo (int) $i; ?>" style="padding:14px 24px;max-width:860px;margin:10px 0;">
					<h3 style="margin-top:0;"><?php echo esc_html( $qa[0] ); ?></h3>
					<p style="font-size:14px;line-height:1.7;"><?php echo wp_kses_post( $qa[1] ); ?></p>
				</div>
			<?php endforeach; ?>

			<p style="max-width:860px;color:#646970;">
				<?php
				printf(
					/* translators: %s: reloom.life link */
					wp_kses_post( __( 'Still stuck? Visit %s or the Journal for guides and support.', 'reloom-human-design' ) ),
					'<a href="https://reloom.life" target="_blank" rel="noopener">reloom.life</a>'
				);
				?>
			</p>
		</div>
		<?php
	}

	/**
	 * @param string $hook Current admin page hook.
	 */
	public static function assets( $hook ) {
		if ( false === strpos( (string) $hook, self::MENU_SLUG ) ) {
			return;
		}
		wp_enqueue_style( 'rbhdc-admin', RBHDC_PLUGIN_URL . 'assets/css/admin.css', array(), RBHDC_VERSION );
		wp_enqueue_script( 'rbhdc-admin', RBHDC_PLUGIN_URL . 'assets/js/client.js', array( 'jquery' ), RBHDC_VERSION, true );
		wp_localize_script( 'rbhdc-admin', 'rbhdc', array(
			'ajaxUrl'     => admin_url( 'admin-ajax.php' ),
			'nonce'       => wp_create_nonce( self::NONCE ),
			'profilesUrl' => admin_url( 'admin.php?page=' . self::MENU_SLUG ),
		) );
	}

	/* --------------------------------------------------------------------- */
	/* Settings page                                                          */
	/* --------------------------------------------------------------------- */

	/**
	 * One-time upgrade: the pre-2.0 plugin stored an HD Suite base
	 * (…/wp-json/rbhd/v1). That endpoint means nothing to Reloom, so clear the
	 * stale base + token once — the user then reconnects to Reloom. Guarded to
	 * the legacy rbhd path so a valid Reloom /api/v1 config is never touched.
	 */
	public static function maybe_migrate_legacy() {
		if ( get_option( 'rbhdc_migrated_v2' ) ) {
			return;
		}
		$s = get_option( RBHDC_Client::OPTION, array() );
		if ( is_array( $s ) && ! empty( $s['api_base'] ) && preg_match( '#/wp-json/rbhd#i', (string) $s['api_base'] ) ) {
			unset( $s['api_base'], $s['api_token'] );
			update_option( RBHDC_Client::OPTION, $s, false );
		}
		update_option( 'rbhdc_migrated_v2', 1, false );
	}

	/**
	 * One-time: readings cached before the Plain/HD voices feature (or during
	 * its rollout) can hold the wrong voice under a voice-suffixed key, which
	 * made PDF exports mix voices. Clear all cached readings once; in-plan
	 * readings re-pull automatically on the next profile view, and Reloom
	 * caches each voice server-side so re-pulls are fast and not re-billed.
	 * Charts are untouched (they have no voice).
	 */
	public static function maybe_flush_prevoice_readings() {
		if ( get_option( 'rbhdc_readings_flushed_v123' ) ) {
			return;
		}
		foreach ( self::get_all() as $r ) {
			if ( ! empty( $r['id'] ) ) {
				delete_option( 'rbhdc_readings_' . $r['id'] );
			}
		}
		update_option( 'rbhdc_readings_flushed_v123', 1, false );
	}

	/** Per-user transient holding the in-flight connect handshake (PKCE + state). */
	private static function connect_state_key() {
		return 'rbhdc_connect_' . get_current_user_id();
	}

	/**
	 * Drive the "Connect to Reloom" wizard. Runs on admin_init (before any output
	 * so redirects work). Two legs:
	 *  1. Start  (?rbhdc_connect=1): mint PKCE, stash it, bounce to Reloom.
	 *  2. Return (?code&state | ?error): verify, exchange the code for a token.
	 */
	public static function maybe_handle_connect() {
		if ( ! is_admin() || ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
		if ( self::SETTINGS_SLUG !== $page ) {
			return;
		}
		$settings_url = admin_url( 'admin.php?page=' . self::SETTINGS_SLUG );

		// Leg 1 — start.
		if ( isset( $_GET['rbhdc_connect'] ) ) {
			check_admin_referer( 'rbhdc_connect' );
			$verifier  = RBHDC_Client::pkce_verifier();
			$challenge = RBHDC_Client::pkce_challenge( $verifier );
			$state     = wp_generate_password( 32, false );
			// 30 min, not 10 — a first-time user gets bounced through sign-up and
			// the onboarding wizard on Reloom before approving, which can easily
			// take longer than 10 minutes. If this transient expires before they
			// return with the code, the exchange fails on a missing verifier. The
			// handshake is still safe: per-user, single-use, overwritten each try,
			// and the code itself expires server-side in 10 min once minted.
			set_transient( self::connect_state_key(), array( 'verifier' => $verifier, 'state' => $state ), 30 * MINUTE_IN_SECONDS );
			// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- deliberate external redirect to the user's own Reloom host to authorize the connection (nonce-checked above); wp_safe_redirect() would strip it.
			wp_redirect( RBHDC_Client::connect_authorize_url( $state, $challenge, $settings_url ) );
			exit;
		}

		// Leg 2 — return from Reloom.
		if ( isset( $_GET['error'] ) ) {
			delete_transient( self::connect_state_key() );
			wp_safe_redirect( add_query_arg( 'rbhdc_error', 'denied', $settings_url ) );
			exit;
		}
		if ( isset( $_GET['code'], $_GET['state'] ) ) {
			$saved = get_transient( self::connect_state_key() );
			$code  = sanitize_text_field( wp_unslash( $_GET['code'] ) );
			$state = sanitize_text_field( wp_unslash( $_GET['state'] ) );
			delete_transient( self::connect_state_key() );

			if ( ! is_array( $saved ) || empty( $saved['state'] ) || ! hash_equals( (string) $saved['state'], $state ) ) {
				wp_safe_redirect( add_query_arg( 'rbhdc_error', 'state', $settings_url ) );
				exit;
			}
			$res = RBHDC_Client::exchange( $code, (string) $saved['verifier'] );
			if ( is_wp_error( $res ) || empty( $res['token'] ) ) {
				wp_safe_redirect( add_query_arg( 'rbhdc_error', 'exchange', $settings_url ) );
				exit;
			}
			$base = ! empty( $res['api_base'] ) ? (string) $res['api_base'] : ( RBHDC_Client::settings()['reloom_host'] . '/api/v1' );
			RBHDC_Client::save_settings( $base, (string) $res['token'] );
			delete_transient( 'rbhdc_meta_' . md5( wp_json_encode( RBHDC_Client::settings() ) ) );
			// Auto-sync: adopt the account's existing profiles (above all the
			// owner's own self-profile) so they appear here immediately and are
			// never re-added. Best-effort — a hiccup never blocks the connection.
			$pulled = self::pull_and_import();
			wp_safe_redirect( add_query_arg( array( 'rbhdc_connected' => '1', 'rbhdc_pulled' => (int) $pulled ), $settings_url ) );
			exit;
		}
	}

	public static function render_settings_page() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$s           = RBHDC_Client::settings();
		$connected   = RBHDC_Client::is_configured();
		$connect_url = wp_nonce_url( add_query_arg( 'rbhdc_connect', '1', admin_url( 'admin.php?page=' . self::SETTINGS_SLUG ) ), 'rbhdc_connect' );
		?>
		<div class="wrap rbhd-wrap rbhdc-settings" data-nonce="<?php echo esc_attr( wp_create_nonce( self::NONCE ) ); ?>">
			<h1><?php esc_html_e( 'Reloom — Settings', 'reloom-human-design' ); ?></h1>

			<?php if ( isset( $_GET['rbhdc_connected'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
				<?php $pulled = isset( $_GET['rbhdc_pulled'] ) ? absint( wp_unslash( $_GET['rbhdc_pulled'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
				<div class="notice notice-success is-dismissible"><p>
					<?php esc_html_e( 'Connected to Reloom. You can now pull charts and readings.', 'reloom-human-design' ); ?>
					<?php
					if ( $pulled > 0 ) {
						echo ' ';
						printf(
							/* translators: %d: number of profiles brought in from Reloom. */
							esc_html( _n( 'Brought in %d profile you already have on Reloom (see Profiles).', 'Brought in %d profiles you already have on Reloom (see Profiles).', $pulled, 'reloom-human-design' ) ),
							absint( $pulled )
						);
					}
					?>
				</p></div>
			<?php elseif ( isset( $_GET['rbhdc_error'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
				<div class="notice notice-error is-dismissible"><p><?php esc_html_e( 'Connection was not completed. Please try again, or connect manually below.', 'reloom-human-design' ); ?></p></div>
			<?php endif; ?>

			<p class="description">
				<?php esc_html_e( 'Connect this site to your Reloom account. That’s all this does — it links WordPress to reloom.life so your charts and readings come straight from Reloom, with nothing else to set up here. Your Reloom plan decides what’s available.', 'reloom-human-design' ); ?>
			</p>

			<div class="rbhd-card" style="max-width:640px;padding:16px 22px;margin:16px 0;">
				<h2 style="margin-top:0;"><?php esc_html_e( 'Connection', 'reloom-human-design' ); ?></h2>
				<p>
					<?php if ( $connected ) : ?>
						<span class="dashicons dashicons-yes-alt" style="color:#2c8a7d;"></span>
						<?php
						printf(
							/* translators: %s: API base URL. */
							esc_html__( 'Connected to %s', 'reloom-human-design' ),
							'<code>' . esc_html( $s['api_base'] ) . '</code>'
						);
						$meta_now = RBHDC_Client::meta();
						// Prefer the human plan label from Reloom ("Practitioner");
						// fall back to a prettified plan key on older servers.
						$plan_key   = ( is_array( $meta_now ) && ! empty( $meta_now['plan'] ) ) ? (string) $meta_now['plan'] : '';
						$plan_name  = ( is_array( $meta_now ) && ! empty( $meta_now['plan_label'] ) )
							? (string) $meta_now['plan_label']
							: ucwords( str_replace( array( '-', '_' ), ' ', $plan_key ) );
						$quota_text = '';
						if ( is_array( $meta_now ) && isset( $meta_now['profiles']['limit'] ) ) {
							$quota_text = sprintf(
								/* translators: 1: profiles in this roster, 2: plan limit. */
								__( '%1$d/%2$d profiles', 'reloom-human-design' ),
								count( self::get_all() ),
								absint( $meta_now['profiles']['limit'] )
							);
						}
						$n_scopes = count( RBHDC_Client::active_scopes() );
						?>
						<?php if ( '' !== $plan_key ) : ?>
							<br>
							<span class="dashicons dashicons-awards" style="color:#2c8a7d;vertical-align:text-bottom;"></span>
							<?php
							printf(
								/* translators: 1: plan name, 2: profile quota ("2/26 profiles", may be empty), 3: number of readings enabled. */
								esc_html__( 'Plan: %1$s — %2$s%3$d readings enabled here. Your plan decides what’s available.', 'reloom-human-design' ),
								'<strong>' . esc_html( $plan_name ) . '</strong>',
								'' !== $quota_text ? esc_html( $quota_text ) . ' · ' : '',
								(int) $n_scopes
							);
							?>
						<?php endif; ?>
					<?php else : ?>
						<span class="dashicons dashicons-warning" style="color:#b26a00;"></span>
						<?php esc_html_e( 'Not connected yet.', 'reloom-human-design' ); ?>
					<?php endif; ?>
				</p>
				<p>
					<a href="<?php echo esc_url( $connect_url ); ?>" class="button button-primary">
						<?php echo $connected ? esc_html__( 'Reconnect to Reloom', 'reloom-human-design' ) : esc_html__( 'Connect to Reloom', 'reloom-human-design' ); ?>
					</a>
					<button type="button" class="button rbhdc-test"><?php esc_html_e( 'Test connection', 'reloom-human-design' ); ?></button>
					<span class="rbhdc-settings-status description" style="margin-left:8px;" aria-live="polite"></span>
				</p>
				<p class="description"><?php esc_html_e( 'You’ll be sent to Reloom to approve this site, then returned here automatically. The token is delivered securely — it never appears in the browser URL.', 'reloom-human-design' ); ?></p>
			</div>

			<details class="rbhd-card" style="max-width:640px;padding:0 22px;margin:16px 0;" <?php echo $connected ? '' : 'open'; ?>>
				<summary style="padding:14px 0;cursor:pointer;font-weight:600;"><?php esc_html_e( 'Advanced — connect manually', 'reloom-human-design' ); ?></summary>
				<p class="description"><?php esc_html_e( 'Paste the API base URL (…/api/v1) and a token created under API access in your Reloom dashboard. You can also paste the whole “Shareable URL” into the base field — the key is split out automatically.', 'reloom-human-design' ); ?></p>
				<table class="form-table">
					<tr>
						<th scope="row"><label for="rbhdc-host"><?php esc_html_e( 'Reloom site', 'reloom-human-design' ); ?></label></th>
						<td><input type="text" id="rbhdc-host" class="regular-text code" value="<?php echo esc_attr( $s['reloom_host'] ); ?>" placeholder="https://reloom.life" style="width:480px;max-width:100%;" /></td>
					</tr>
					<tr>
						<th scope="row"><label for="rbhdc-base"><?php esc_html_e( 'API base URL', 'reloom-human-design' ); ?></label></th>
						<td><input type="text" id="rbhdc-base" class="regular-text code" value="<?php echo esc_attr( $s['api_base'] ); ?>" placeholder="https://reloom.life/api/v1" style="width:480px;max-width:100%;" /></td>
					</tr>
					<tr>
						<th scope="row"><label for="rbhdc-token"><?php esc_html_e( 'API token', 'reloom-human-design' ); ?></label></th>
						<td><input type="text" id="rbhdc-token" class="regular-text code" value="<?php echo esc_attr( $s['api_token'] ); ?>" placeholder="<?php esc_attr_e( 'Paste your Reloom API token…', 'reloom-human-design' ); ?>" style="width:480px;max-width:100%;" /></td>
					</tr>
				</table>
				<p>
					<button type="button" class="button button-primary rbhdc-save-settings"><?php esc_html_e( 'Save', 'reloom-human-design' ); ?></button>
					<button type="button" class="button rbhdc-test"><?php esc_html_e( 'Save & test connection', 'reloom-human-design' ); ?></button>
					<span class="rbhdc-settings-status description" style="margin-left:8px;" aria-live="polite"></span>
				</p>
			</details>

			<table class="form-table" style="max-width:640px;">
				<tr>
					<th scope="row"><?php esc_html_e( 'Data sharing', 'reloom-human-design' ); ?></th>
					<td>
						<label>
							<input type="checkbox" id="rbhdc-sync" <?php checked( ! empty( $s['sync'] ) ); ?> />
							<?php esc_html_e( 'Add profiles created here to my Reloom dashboard', 'reloom-human-design' ); ?>
						</label>
						<p class="description"><?php esc_html_e( 'Enabled by default. When on, each profile you create here is synced to your Reloom account (subject to your plan’s profile limit).', 'reloom-human-design' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><?php esc_html_e( 'PDF branding', 'reloom-human-design' ); ?></th>
					<td>
						<label>
							<input type="checkbox" id="rbhdc-pdf-branding" <?php checked( ! empty( $s['pdf_branding'] ) ); ?> />
							<?php esc_html_e( 'Show a “Powered by” footer (logo + link) in exported PDFs', 'reloom-human-design' ); ?>
						</label>
						<p class="description"><?php esc_html_e( 'Off by default. Exported PDFs contain no branding unless you enable this.', 'reloom-human-design' ); ?></p>
					</td>
				</tr>
			</table>
			<p>
				<button type="button" class="button rbhdc-save-settings"><?php esc_html_e( 'Save data-sharing preference', 'reloom-human-design' ); ?></button>
				<span class="rbhdc-settings-status description" style="margin-left:8px;" aria-live="polite"></span>
			</p>
			<div class="rbhdc-settings-result"></div>
		</div>
		<?php
	}

	/* --------------------------------------------------------------------- */
	/* Profiles list                                                          */
	/* --------------------------------------------------------------------- */

	public static function render_profiles_page() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$id = isset( $_GET['id'] ) ? sanitize_text_field( wp_unslash( $_GET['id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( '' !== $id && self::find( $id ) ) {
			self::render_detail( self::find( $id ) );
			return;
		}
		self::render_list();
	}

	private static function render_list() {
		$rows       = self::get_all();
		// The account owner's own profile (pulled from Reloom on connect) leads.
		usort(
			$rows,
			static function ( $a, $b ) {
				return (int) ! empty( $b['is_self'] ) - (int) ! empty( $a['is_self'] );
			}
		);
		$configured = RBHDC_Client::is_configured();
		$nonce      = wp_create_nonce( self::NONCE );
		?>
		<div class="wrap rbhd-wrap rbhdc-profiles" data-nonce="<?php echo esc_attr( $nonce ); ?>">
			<h1 class="wp-heading-inline"><?php esc_html_e( 'Human Design', 'reloom-human-design' ); ?> <span class="count">(<span class="rbhdc-count"><?php echo (int) count( $rows ); ?></span>)</span></h1>
			<button type="button" class="page-title-action rbhdc-add-toggle"><?php esc_html_e( 'Add new', 'reloom-human-design' ); ?></button>
			<button type="button" class="page-title-action rbhdc-export"><?php esc_html_e( 'Export JSON', 'reloom-human-design' ); ?></button>
			<button type="button" class="page-title-action rbhdc-import-toggle"><?php esc_html_e( 'Import JSON', 'reloom-human-design' ); ?></button>
			<?php
			// Plan usage pill — "Practitioner · 8/25 profiles": the profiles in
			// THIS roster against the connected plan's limit (from the briefly-
			// cached /meta; hidden on error or when not connected).
			if ( $configured ) {
				$meta = RBHDC_Client::meta();
				if ( ! is_wp_error( $meta ) && isset( $meta['profiles']['limit'] ) ) {
					$used  = count( $rows );
					$limit = (int) $meta['profiles']['limit'];
					$plan  = ! empty( $meta['plan_label'] ) ? (string) $meta['plan_label'] : '';
					?>
					<span class="rbhdc-plan-pill <?php echo $used >= $limit ? 'is-full' : ''; ?>"
						title="<?php esc_attr_e( 'Profiles here against your Reloom plan’s limit.', 'reloom-human-design' ); ?>">
						<?php
						if ( '' !== $plan ) {
							echo esc_html( $plan ) . ' · ';
						}
						printf(
							/* translators: 1: profiles used, 2: plan limit. */
							esc_html__( '%1$d/%2$d profiles', 'reloom-human-design' ),
							absint( $used ),
							absint( $limit )
						);
						?>
					</span>
					<?php
				}
			}
			?>
			<hr class="wp-header-end" />

			<?php if ( ! $configured ) : ?>
				<div class="notice notice-warning"><p>
					<?php
					printf(
						/* translators: %s: settings URL. */
						wp_kses_post( __( 'Not connected yet. Add the API URL + token on the <a href="%s">Settings</a> page to pull charts and readings.', 'reloom-human-design' ) ),
						esc_url( admin_url( 'admin.php?page=' . self::SETTINGS_SLUG ) )
					);
					?>
				</p></div>
			<?php endif; ?>

			<div class="rbhdc-import-form rbhd-card" style="display:none;max-width:520px;padding:12px 18px;margin:12px 0;">
				<p><strong><?php esc_html_e( 'Import profiles (JSON)', 'reloom-human-design' ); ?></strong></p>
				<input type="file" class="rbhdc-import-file" accept="application/json,.json" />
				<button type="button" class="button rbhdc-import-go"><?php esc_html_e( 'Import', 'reloom-human-design' ); ?></button>
				<span class="rbhdc-import-status description"></span>
			</div>

			<div class="rbhdc-add-form rbhd-card" style="<?php echo empty( $rows ) ? '' : 'display:none;'; ?>max-width:760px;padding:14px 22px;margin:12px 0;">
				<?php self::render_birth_form(); ?>
			</div>

			<div class="rbhdc-filters" style="margin:10px 0;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
				<input type="search" class="rbhdc-search" placeholder="<?php esc_attr_e( 'Search name or place…', 'reloom-human-design' ); ?>" style="min-width:240px;" />
				<select class="rbhdc-filter-gender">
					<option value=""><?php esc_html_e( 'All genders', 'reloom-human-design' ); ?></option>
					<option value="female"><?php esc_html_e( 'Female', 'reloom-human-design' ); ?></option>
					<option value="male"><?php esc_html_e( 'Male', 'reloom-human-design' ); ?></option>
				</select>
				<select class="rbhdc-sort">
					<option value="created-desc"><?php esc_html_e( 'Newest first', 'reloom-human-design' ); ?></option>
					<option value="created-asc"><?php esc_html_e( 'Oldest first', 'reloom-human-design' ); ?></option>
					<option value="name-asc"><?php esc_html_e( 'Name A→Z', 'reloom-human-design' ); ?></option>
				</select>
				<span class="rbhdc-filter-count description"></span>
			</div>

			<table class="wp-list-table widefat fixed striped rbhd-profiles-table rbhdc-table">
				<thead><tr>
					<th class="column-primary"><?php esc_html_e( 'Name', 'reloom-human-design' ); ?></th>
					<th><?php esc_html_e( 'Gender', 'reloom-human-design' ); ?></th>
					<th><?php esc_html_e( 'Birth', 'reloom-human-design' ); ?></th>
					<th><?php esc_html_e( 'Place', 'reloom-human-design' ); ?></th>
					<th><?php esc_html_e( 'Status', 'reloom-human-design' ); ?></th>
					<th><?php esc_html_e( 'Actions', 'reloom-human-design' ); ?></th>
				</tr></thead>
				<tbody>
				<?php if ( empty( $rows ) ) : ?>
					<tr class="rbhdc-empty-row"><td colspan="6"><?php esc_html_e( 'No people yet. Click “Add new”.', 'reloom-human-design' ); ?></td></tr>
				<?php else : ?>
					<?php foreach ( $rows as $row ) : self::render_row( $row ); endforeach; ?>
				<?php endif; ?>
				</tbody>
			</table>
		</div>
		<?php
	}

	/**
	 * One profile row with a Status column (Chart / N readings) like the Suite.
	 *
	 * @param array $row Profile row.
	 */
	private static function render_row( array $row ) {
		$url        = add_query_arg( array( 'page' => self::MENU_SLUG, 'id' => $row['id'] ), admin_url( 'admin.php' ) );
		$has_chart  = (bool) self::get_chart( $row['id'] );
		$n_readings = count( self::get_readings( $row['id'] ) );
		$birth      = trim( ( $row['date'] ?? '' ) . ' ' . ( $row['time'] ?? '' ) );
		?>
		<tr data-id="<?php echo esc_attr( $row['id'] ); ?>"
			data-name="<?php echo esc_attr( strtolower( $row['name'] . ' ' . ( $row['place'] ?? '' ) ) ); ?>"
			data-gender="<?php echo esc_attr( $row['gender'] ?? '' ); ?>"
			data-created="<?php echo esc_attr( $row['created'] ?? 0 ); ?>">
			<td class="column-primary"><strong><a href="<?php echo esc_url( $url ); ?>"><?php echo esc_html( $row['name'] ); ?></a></strong>
				<?php if ( ! empty( $row['is_self'] ) ) : ?>
					<span class="rbhdc-you-badge" style="margin-left:6px;padding:1px 7px;border-radius:9px;background:#0f5c5c;color:#fff;font-size:11px;font-weight:600;vertical-align:middle;"><?php esc_html_e( 'You', 'reloom-human-design' ); ?></span>
				<?php endif; ?>
			</td>
			<td><?php echo esc_html( $row['gender'] ? $row['gender'] : '—' ); ?></td>
			<td><?php echo esc_html( $birth ?: '—' ); ?></td>
			<td><?php echo esc_html( $row['place'] ? $row['place'] : ( $row['timezone'] ?? '—' ) ); ?></td>
			<td class="rbhdc-status">
				<span class="rbhd-status-pill <?php echo $has_chart ? 'is-on' : ''; ?>"><?php esc_html_e( 'Chart', 'reloom-human-design' ); ?></span>
				<span class="rbhd-status-pill <?php echo $n_readings ? 'is-on' : ''; ?>"><?php echo (int) $n_readings; ?> <?php esc_html_e( 'readings', 'reloom-human-design' ); ?></span>
			</td>
			<td>
				<a class="button button-small" href="<?php echo esc_url( $url ); ?>"><?php esc_html_e( 'Open', 'reloom-human-design' ); ?></a>
				<a class="button button-small" href="<?php echo esc_url( add_query_arg( 'edit', '1', $url ) ); ?>"><?php esc_html_e( 'Edit', 'reloom-human-design' ); ?></a>
				<button type="button" class="button button-small rbhdc-delete" data-id="<?php echo esc_attr( $row['id'] ); ?>" style="color:#b32d2e;"><?php esc_html_e( 'Delete', 'reloom-human-design' ); ?></button>
			</td>
		</tr>
		<?php
	}

	/**
	 * Birth Data form (mirrors the HD Suite chart form).
	 *
	 * @param array|null $row Existing row.
	 */
	private static function render_birth_form( $row = null, $context = 'add' ) {
		$g = $row['gender'] ?? '';
		?>
		<h2 style="margin-top:0;"><?php echo 'edit' === $context ? esc_html__( 'Edit profile', 'reloom-human-design' ) : esc_html__( 'Birth Data', 'reloom-human-design' ); ?></h2>
		<form class="rbhdc-form" data-id="<?php echo esc_attr( $row['id'] ?? '' ); ?>" data-context="<?php echo esc_attr( $context ); ?>">
			<div class="rbhdc-row2">
				<p><label><?php esc_html_e( 'First name', 'reloom-human-design' ); ?><br>
					<input type="text" name="first_name" value="<?php echo esc_attr( $row['first_name'] ?? '' ); ?>" class="regular-text" /></label></p>
				<p><label><?php esc_html_e( 'Family name (surname)', 'reloom-human-design' ); ?><br>
					<input type="text" name="last_name" value="<?php echo esc_attr( $row['last_name'] ?? '' ); ?>" class="regular-text" /></label></p>
			</div>
			<p><label><?php esc_html_e( 'Gender', 'reloom-human-design' ); ?><br>
				<select name="gender">
					<option value="" <?php selected( $g, '' ); ?>><?php esc_html_e( '— unspecified —', 'reloom-human-design' ); ?></option>
					<option value="female" <?php selected( $g, 'female' ); ?>><?php esc_html_e( 'Female', 'reloom-human-design' ); ?></option>
					<option value="male" <?php selected( $g, 'male' ); ?>><?php esc_html_e( 'Male', 'reloom-human-design' ); ?></option>
				</select></label></p>
			<p><label><?php esc_html_e( 'Date of birth', 'reloom-human-design' ); ?><br>
				<input type="date" name="date" value="<?php echo esc_attr( $row['date'] ?? '' ); ?>" /></label></p>
			<p><label><?php esc_html_e( 'Time of birth', 'reloom-human-design' ); ?><br>
				<input type="time" name="time" value="<?php echo esc_attr( $row['time'] ?? '' ); ?>" /></label></p>
			<p><label><?php esc_html_e( 'Place of birth', 'reloom-human-design' ); ?><br>
				<span class="rbhdc-place-wrap rbhd-place" style="position:relative;display:block;width:100%;max-width:540px;">
					<input type="text" name="place" value="<?php echo esc_attr( $row['place'] ?? '' ); ?>" class="rbhdc-place" autocomplete="off" placeholder="<?php esc_attr_e( 'Start typing a city…', 'reloom-human-design' ); ?>" style="width:100%;" />
					<ul class="rbhdc-place-results" hidden></ul>
				</span>
				<span class="rbhdc-place-status" style="font-weight:600;display:inline-block;margin-top:4px;"></span></label></p>
			<p><label><?php esc_html_e( 'Timezone (optional, IANA)', 'reloom-human-design' ); ?><br>
				<input type="text" name="timezone" value="<?php echo esc_attr( $row['timezone'] ?? '' ); ?>" class="regular-text rbhdc-timezone" placeholder="Europe/Amsterdam" /></label>
				<span class="description"><?php esc_html_e( 'Filled in when you pick a city. Fallback if the city can’t be resolved.', 'reloom-human-design' ); ?></span></p>
			<p><label><?php esc_html_e( 'Email (optional)', 'reloom-human-design' ); ?><br>
				<input type="email" name="email" value="<?php echo esc_attr( $row['email'] ?? '' ); ?>" class="regular-text" /></label></p>
			<p><label><?php esc_html_e( 'Comment / notes (optional)', 'reloom-human-design' ); ?><br>
				<textarea name="notes" rows="2" class="large-text" placeholder="<?php esc_attr_e( 'Private notes about this person — only visible in the admin.', 'reloom-human-design' ); ?>"><?php echo esc_textarea( $row['notes'] ?? '' ); ?></textarea></label></p>
			<p>
				<?php if ( 'edit' === $context ) : ?>
					<button type="button" class="button button-primary rbhdc-save"><?php esc_html_e( 'Save changes', 'reloom-human-design' ); ?></button>
					<button type="button" class="button rbhdc-edit-cancel"><?php esc_html_e( 'Cancel', 'reloom-human-design' ); ?></button>
				<?php else : ?>
					<button type="button" class="button button-primary rbhdc-generate"><?php esc_html_e( 'Generate Chart', 'reloom-human-design' ); ?></button>
					<button type="button" class="button rbhdc-save"><?php esc_html_e( 'Save profile', 'reloom-human-design' ); ?></button>
				<?php endif; ?>
				<span class="rbhdc-form-status description" style="margin-left:8px;" aria-live="polite"></span>
			</p>
		</form>
		<?php
	}

	/* --------------------------------------------------------------------- */
	/* Profile detail (suite-style tabs + stored content + Refresh)           */
	/* --------------------------------------------------------------------- */

	private static function render_detail( array $row ) {
		$nonce  = wp_create_nonce( self::NONCE );
		$scopes = RBHDC_Client::active_scopes();
		$meta   = RBHDC_Client::meta();
		$err    = is_wp_error( $meta ) ? $meta->get_error_message() : '';
		$labels = self::scope_labels();
		$order  = array_keys( $labels ); // chart first, then readings.
		$tabs   = array();
		foreach ( $order as $sc ) {
			if ( in_array( $sc, $scopes, true ) ) {
				$tabs[ $sc ] = $labels[ $sc ];
			}
		}
		$readings = self::get_readings( $row['id'] );
		$chart    = self::get_chart( $row['id'] );
		$back     = admin_url( 'admin.php?page=' . self::MENU_SLUG );
		?>
		<?php $edit_open = ! empty( $_GET['edit'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
		<div class="wrap rbhd-wrap rbhdc-detail" data-nonce="<?php echo esc_attr( $nonce ); ?>" data-id="<?php echo esc_attr( $row['id'] ); ?>">
			<p><a href="<?php echo esc_url( $back ); ?>">&larr; <?php esc_html_e( 'All profiles', 'reloom-human-design' ); ?></a></p>
			<h1 style="display:inline-block;margin-right:10px;"><?php echo esc_html( $row['name'] ); ?></h1>
			<button type="button" class="page-title-action rbhdc-edit-toggle"><?php esc_html_e( 'Edit profile', 'reloom-human-design' ); ?></button>
			<?php if ( $chart || ! empty( $readings ) ) : ?>
				<button type="button" class="page-title-action rbhdc-pdf">
					<span class="dashicons dashicons-pdf" style="vertical-align:text-bottom;"></span>
					<?php esc_html_e( 'Download PDF', 'reloom-human-design' ); ?>
				</button>
			<?php endif; ?>
			<p class="description">
				<?php echo esc_html( trim( ( $row['date'] ?? '' ) . ' ' . ( $row['time'] ?? '' ) ) ); ?>
				<?php echo $row['place'] ? ' · ' . esc_html( $row['place'] ) : ''; ?>
				<?php echo $row['timezone'] ? ' · ' . esc_html( $row['timezone'] ) : ''; ?>
			</p>

			<div class="rbhdc-edit-form rbhd-card" style="<?php echo $edit_open ? '' : 'display:none;'; ?>max-width:760px;padding:14px 22px;margin:12px 0;">
				<?php self::render_birth_form( $row, 'edit' ); ?>
			</div>

			<?php if ( '' !== $err ) : ?>
				<div class="notice notice-error"><p><?php echo esc_html( $err ); ?>
					<a href="<?php echo esc_url( admin_url( 'admin.php?page=' . self::SETTINGS_SLUG ) ); ?>"><?php esc_html_e( 'Check Settings', 'reloom-human-design' ); ?></a>
				</p></div>
			<?php elseif ( empty( $tabs ) ) : ?>
				<div class="notice notice-warning"><p><?php esc_html_e( 'Your Reloom plan includes no content for this connection. Upgrade your plan in the Reloom dashboard to unlock readings.', 'reloom-human-design' ); ?></p></div>
			<?php else : ?>
				<?php
				$chart_enabled  = in_array( 'chart', $scopes, true );
				$reading_scopes = array_values( array_intersect( self::reading_slots(), $scopes ) );
				$default_style  = 'plain'; // Plain voice by default; JS toggles to 'hd'.
				?>
				<div class="rbhdc-split">
					<div class="rbhdc-split-col rbhdc-split-left">
						<?php if ( $chart_enabled ) : ?>
							<nav class="rbhd-tabs nav-tab-wrapper" role="tablist">
								<span class="rbhd-tab nav-tab nav-tab-active rbhdc-static-tab" role="tab" aria-selected="true">
									<span class="rbhd-tab-label"><?php echo esc_html( $labels['chart'] ); ?></span>
									<?php if ( $chart ) : ?><span class="rbhd-tab-dot" aria-hidden="true"></span><?php endif; ?>
								</span>
							</nav>
							<div class="rbhd-tab-panels">
								<?php
								$chart_html = $chart ? RBHDC_Chart_Renderer::render( $chart['data'] ) : '';
								self::render_content_panel( 'chart', $labels['chart'], $chart_html, $chart['at'] ?? 0, true );
								?>
							</div>
						<?php else : ?>
							<p class="description"><?php esc_html_e( 'The Bodygraph chart isn’t included in your plan.', 'reloom-human-design' ); ?></p>
						<?php endif; ?>
					</div>
					<div class="rbhdc-split-resizer" role="separator" aria-orientation="vertical" tabindex="0" aria-label="<?php esc_attr_e( 'Drag to resize', 'reloom-human-design' ); ?>"><span class="rbhdc-split-grip" aria-hidden="true"></span></div>
					<div class="rbhdc-split-col rbhdc-split-right">
						<?php if ( $reading_scopes ) : ?>
							<div class="rbhdc-reading-head">
							<nav class="rbhd-tabs nav-tab-wrapper" role="tablist">
								<?php $first = true; ?>
								<?php foreach ( $reading_scopes as $sc ) : ?>
									<a href="#" class="rbhd-tab nav-tab <?php echo $first ? 'nav-tab-active' : ''; ?>" data-scope="<?php echo esc_attr( $sc ); ?>" role="tab">
										<span class="rbhd-tab-label"><?php echo esc_html( $labels[ $sc ] ); ?></span>
										<?php if ( ! empty( $readings[ $sc . '|' . $default_style ] ) ) : ?><span class="rbhd-tab-dot" aria-hidden="true"></span><?php endif; ?>
									</a>
									<?php $first = false; ?>
								<?php endforeach; ?>
							</nav>
							<div class="rbhdc-style-toggle" role="group" aria-label="<?php esc_attr_e( 'Reading style', 'reloom-human-design' ); ?>">
								<button type="button" class="rbhdc-style is-active" data-style="plain"><?php esc_html_e( 'Plain', 'reloom-human-design' ); ?></button>
								<button type="button" class="rbhdc-style" data-style="hd"><?php esc_html_e( 'Human Design', 'reloom-human-design' ); ?></button>
							</div>
							</div><!-- /.rbhdc-reading-head -->
							<div class="rbhd-tab-panels">
								<?php $first = true; ?>
								<?php foreach ( $reading_scopes as $sc ) : ?>
									<?php
									$entry      = $readings[ $sc . '|' . $default_style ] ?? null;
									$reading_html = ( $entry && ! empty( $entry['text'] ) ) ? '<div class="rbhd-reading-body">' . RBHDC_Chart_Renderer::markdown_to_html( $entry['text'] ) . '</div>' : '';
									self::render_content_panel( $sc, $labels[ $sc ], $reading_html, $entry['at'] ?? 0, $first );
									$first = false;
									?>
								<?php endforeach; ?>
							</div>
						<?php else : ?>
							<p class="description"><?php esc_html_e( 'No readings are included in your plan yet.', 'reloom-human-design' ); ?></p>
						<?php endif; ?>
					</div>
				</div>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * One content panel (chart or reading). Auto-loads when empty.
	 *
	 * @param string $scope       Scope key.
	 * @param string $label       Heading.
	 * @param string $stored_html Cached HTML ('' if not yet pulled).
	 * @param int    $stored_at   Cache timestamp.
	 * @param bool   $active      Visible by default?
	 */
	private static function render_content_panel( $scope, $label, $stored_html, $stored_at, $active ) {
		?>
		<section class="rbhd-tab-panel <?php echo $active ? 'is-active' : ''; ?>" data-scope="<?php echo esc_attr( $scope ); ?>" role="tabpanel" aria-label="<?php echo esc_attr( $label ); ?>" <?php echo $active ? '' : 'hidden'; ?>>
			<div class="rbhdc-panel-toolbar">
				<?php if ( 'chart' === $scope ) : ?>
					<span class="rbhdc-zoom" role="group" aria-label="<?php esc_attr_e( 'Zoom chart', 'reloom-human-design' ); ?>">
						<button type="button" class="button button-small rbhdc-zoom-out" aria-label="<?php esc_attr_e( 'Zoom out', 'reloom-human-design' ); ?>">&minus;</button>
						<span class="rbhdc-zoom-val">55%</span>
						<button type="button" class="button button-small rbhdc-zoom-in" aria-label="<?php esc_attr_e( 'Zoom in', 'reloom-human-design' ); ?>">+</button>
					</span>
				<?php endif; ?>
				<span class="rbhdc-stored description">
					<?php
					if ( $stored_at ) {
						printf( /* translators: %s: time diff. */ esc_html__( 'Stored %s ago', 'reloom-human-design' ), esc_html( human_time_diff( (int) $stored_at, time() ) ) );
					}
					?>
				</span>
				<button type="button" class="button button-small rbhdc-refresh">
					<span class="dashicons dashicons-update" style="vertical-align:text-bottom;"></span>
					<?php echo $stored_html ? esc_html__( 'Refresh', 'reloom-human-design' ) : esc_html__( 'Generate', 'reloom-human-design' ); ?>
				</button>
			</div>
			<div class="rbhdc-content" data-loaded="<?php echo $stored_html ? '1' : '0'; ?>">
				<?php
				if ( $stored_html ) {
					echo $stored_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by renderer/kses.
				} else {
					echo '<p class="rbhdc-loading"><span class="spinner is-active" style="float:none;"></span> ' . esc_html__( 'Loading…', 'reloom-human-design' ) . '</p>';
				}
				?>
			</div>
		</section>
		<?php
	}

	/* --------------------------------------------------------------------- */
	/* AJAX                                                                    */
	/* --------------------------------------------------------------------- */

	private static function guard() {
		check_ajax_referer( self::NONCE, 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Permission denied.', 'reloom-human-design' ) ), 403 );
		}
	}

	public static function ajax_save_settings() {
		self::guard(); // Nonce + capability checked in guard(); phpcs cannot see through the helper.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$sync = isset( $_POST['sync'] ) ? in_array( sanitize_text_field( wp_unslash( $_POST['sync'] ) ), array( '1', 'true' ), true ) : null;
		$host = isset( $_POST['host'] ) ? sanitize_text_field( wp_unslash( $_POST['host'] ) ) : null;
		if ( isset( $_POST['pdf_branding'] ) ) {
			RBHDC_Client::set_pdf_branding( in_array( sanitize_text_field( wp_unslash( $_POST['pdf_branding'] ) ), array( '1', 'true' ), true ) );
		}
		RBHDC_Client::save_settings(
			isset( $_POST['base'] ) ? sanitize_text_field( wp_unslash( $_POST['base'] ) ) : '',
			isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '',
			$sync,
			$host
		);
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		delete_transient( 'rbhdc_meta_' . md5( wp_json_encode( RBHDC_Client::settings() ) ) );
		wp_send_json_success( array( 'message' => __( 'Saved.', 'reloom-human-design' ) ) );
	}

	public static function ajax_test() {
		self::guard(); // Nonce + capability checked in guard().
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		if ( isset( $_POST['base'], $_POST['token'] ) ) {
			$host = isset( $_POST['host'] ) ? sanitize_text_field( wp_unslash( $_POST['host'] ) ) : null;
			RBHDC_Client::save_settings( sanitize_text_field( wp_unslash( $_POST['base'] ) ), sanitize_text_field( wp_unslash( $_POST['token'] ) ), null, $host );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		$meta = RBHDC_Client::meta( true );
		if ( is_wp_error( $meta ) ) {
			wp_send_json_error( array( 'message' => $meta->get_error_message() ) );
		}
		wp_send_json_success( $meta );
	}

	public static function ajax_save_profile() {
		self::guard(); // Nonce + capability checked in guard().
		// Sanitize immediately at intake — allowlisted fields, matching sanitizers.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized right here by sanitize_profile_input().
		$in = self::sanitize_profile_input( isset( $_POST['profile'] ) && is_array( $_POST['profile'] ) ? wp_unslash( $_POST['profile'] ) : array() );

		// Verify the place of birth against the Bodygraph location database (via
		// the proxy) before saving. A typed place that does not resolve is
		// rejected unless a valid IANA timezone was supplied as a fallback.
		$place = trim( (string) ( $in['place'] ?? '' ) );
		$tz    = trim( (string) ( $in['timezone'] ?? '' ) );
		if ( '' !== $place ) {
			$matches = RBHDC_Client::locations( $place );
			if ( is_array( $matches ) && ! empty( $matches ) ) {
				// Verified — snap to the canonical label + resolved timezone.
				$best          = $matches[0];
				$in['place']   = (string) $best['label'];
				$in['timezone'] = (string) $best['timezone'];
			} else {
				$tz_ok = '' !== $tz && in_array( $tz, timezone_identifiers_list(), true );
				if ( is_wp_error( $matches ) ) {
					if ( ! $tz_ok ) {
						wp_send_json_error( array( 'message' => __( 'Could not verify the place right now (connection issue). Try again, or set a valid IANA timezone as a fallback.', 'reloom-human-design' ) ) );
					}
				} elseif ( ! $tz_ok ) {
					wp_send_json_error( array( 'message' => __( 'Place of birth could not be verified against the location database. Pick a city from the suggestions.', 'reloom-human-design' ) ) );
				}
			}
		}

		$row = self::save( $in );

		// Auto-sync the profile back to the Suite (deduplicated there), when the
		// user has consented and the connection is configured.
		$sync = null;
		if ( RBHDC_Client::is_sync_on() && RBHDC_Client::is_configured() ) {
			$res = RBHDC_Client::sync_profile( $row );
			if ( is_wp_error( $res ) ) {
				$sync = array( 'ok' => false, 'message' => $res->get_error_message() );
			} else {
				$sync = array( 'ok' => true, 'status' => $res['status'] ?? 'created' );
				self::mark_synced( $row['id'], $res['status'] ?? 'created', (string) ( $res['id'] ?? '' ) );
				// A new profile landed on Reloom — refresh the cached /meta so
				// the "x/y profiles" plan pill updates on the next page load.
				delete_transient( 'rbhdc_meta_' . md5( wp_json_encode( RBHDC_Client::settings() ) ) );
			}
		}

		wp_send_json_success( array(
			'id'   => $row['id'],
			'url'  => add_query_arg( array( 'page' => self::MENU_SLUG, 'id' => $row['id'] ), admin_url( 'admin.php' ) ),
			'sync' => $sync,
		) );
	}

	/** Record the sync result (and Reloom's profile id, when known) on a row. */
	private static function mark_synced( $id, $status, $remote_id = '' ) {
		$rows = self::get_all();
		foreach ( $rows as $i => $r ) {
			if ( isset( $r['id'] ) && (string) $r['id'] === (string) $id ) {
				$rows[ $i ]['synced']    = (string) $status;
				$rows[ $i ]['synced_at'] = time();
				if ( '' !== $remote_id ) {
					$rows[ $i ]['remote_id'] = $remote_id;
				}
				update_option( self::PROFILES_OPTION, $rows, false );
				return;
			}
		}
	}

	/**
	 * Pull the account's profiles from Reloom and adopt any not already linked —
	 * matched by Reloom's profile id (remote_id), so nothing is duplicated. The
	 * owner's own "self" profile is flagged is_self. Runs automatically right
	 * after connecting; safe to call again (idempotent). Returns the count added.
	 *
	 * @return int Profiles newly adopted into the local roster.
	 */
	public static function pull_and_import() {
		if ( ! RBHDC_Client::is_configured() ) {
			return 0;
		}
		$list = RBHDC_Client::pull_profiles();
		if ( is_wp_error( $list ) || ! is_array( $list ) ) {
			return 0;
		}
		$rows      = self::get_all();
		$by_remote = array();
		foreach ( $rows as $i => $r ) {
			if ( ! empty( $r['remote_id'] ) ) {
				$by_remote[ (string) $r['remote_id'] ] = $i;
			}
		}
		$added = 0;
		foreach ( $list as $p ) {
			$rid = (string) ( $p['id'] ?? '' );
			if ( '' === $rid ) {
				continue;
			}
			$is_self = ! empty( $p['is_self'] );
			if ( isset( $by_remote[ $rid ] ) ) {
				// Already linked — just keep the self flag current.
				$rows[ $by_remote[ $rid ] ]['is_self'] = $is_self;
				continue;
			}
			$gender = in_array( ( $p['gender'] ?? '' ), array( 'male', 'female' ), true ) ? (string) $p['gender'] : '';
			$rows[] = array(
				'id'        => bin2hex( random_bytes( 8 ) ),
				'remote_id' => $rid,
				'is_self'   => $is_self,
				'first_name' => sanitize_text_field( (string) ( $p['first_name'] ?? '' ) ),
				'last_name' => sanitize_text_field( (string) ( $p['last_name'] ?? '' ) ),
				'name'      => sanitize_text_field( (string) ( $p['name'] ?? '' ) ),
				'gender'    => $gender,
				'date'      => preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) ( $p['date'] ?? '' ) ) ? (string) $p['date'] : '',
				'time'      => preg_match( '/^\d{2}:\d{2}/', (string) ( $p['time'] ?? '' ) ) ? substr( (string) $p['time'], 0, 5 ) : '',
				'place'     => sanitize_text_field( (string) ( $p['place'] ?? '' ) ),
				'timezone'  => sanitize_text_field( (string) ( $p['timezone'] ?? '' ) ),
				'email'     => '',
				'notes'     => '',
				'synced'    => 'synced',
				'synced_at' => time(),
				'created'   => time(),
			);
			++$added;
		}
		update_option( self::PROFILES_OPTION, $rows, false );
		return $added;
	}

	public static function ajax_delete_profile() {
		self::guard(); // Nonce + capability checked in guard().
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		self::delete( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '' );
		wp_send_json_success();
	}

	/**
	 * Fetch one tab's content via the proxy AND store it locally. Returns HTML
	 * plus a "stored ... ago" label so the panel updates after a Refresh.
	 */
	public static function ajax_content() {
		self::guard(); // Nonce + capability checked in guard().
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$id    = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
		$scope = isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : '';
		$style = isset( $_POST['style'] ) ? sanitize_key( wp_unslash( $_POST['style'] ) ) : 'plain';
		if ( 'hd' !== $style ) { $style = 'plain'; }
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		$row   = self::find( $id );
		if ( ! $row ) {
			wp_send_json_error( array( 'message' => __( 'Profile not found.', 'reloom-human-design' ) ), 404 );
		}
		if ( 'chart' === $scope ) {
			$res = RBHDC_Client::chart( $row );
			if ( is_wp_error( $res ) ) {
				wp_send_json_error( array( 'message' => $res->get_error_message() ) );
			}
			$chart = isset( $res['chart'] ) && is_array( $res['chart'] ) ? $res['chart'] : $res;
			self::store_chart( $id, $chart );
			wp_send_json_success( array( 'html' => RBHDC_Chart_Renderer::render( $chart ), 'stored' => __( 'just now', 'reloom-human-design' ) ) );
		}
		$res = RBHDC_Client::reading( $scope, $row, $style );
		if ( is_wp_error( $res ) ) {
			wp_send_json_error( array( 'message' => $res->get_error_message() ) );
		}
		$md = isset( $res['markdown'] ) ? (string) $res['markdown'] : '';
		// Cache each voice separately (slot|style), mirroring reloom.
		self::store_reading( $id, $scope . '|' . $style, $md );
		wp_send_json_success( array(
			'html'   => '<div class="rbhd-reading-body">' . RBHDC_Chart_Renderer::markdown_to_html( $md ) . '</div>',
			'stored' => __( 'just now', 'reloom-human-design' ),
		) );
	}

	/**
	 * Generate a real PDF for a profile with the bundled Dompdf library and
	 * stream it to the browser as a download. No print dialog, no browser
	 * header/footer chrome, real page margins on every page. Works out of the
	 * box on any PHP 7.4+ / WordPress host — Dompdf ships inside the plugin
	 * (vendor/) and is loaded only here, so normal pages pay no cost.
	 */
	public static function ajax_pdf() {
		// Triggered via a download link, so accept GET and verify the nonce
		// from the request (not just POST).
		if ( ! check_ajax_referer( self::NONCE, 'nonce', false ) ) {
			wp_die( esc_html__( 'Security check failed. Please reload and try again.', 'reloom-human-design' ), 403 );
		}
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'reloom-human-design' ), 403 );
		}
		$id  = isset( $_REQUEST['id'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['id'] ) ) : '';
		$row = self::find( $id );
		if ( ! $row ) {
			wp_die( esc_html__( 'Profile not found.', 'reloom-human-design' ), 404 );
		}

		// The browser rasterises the on-screen bodygraph SVG to a PNG and posts
		// it here (PDF libraries render SVG unreliably; a PNG always embeds). We
		// validate it is a genuine PNG data URI before trusting it.
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated + re-encoded by sanitize_png_data_uri() (PNG magic bytes, size cap).
		$chart_png = self::sanitize_png_data_uri( isset( $_POST['chart_png'] ) ? wp_unslash( $_POST['chart_png'] ) : '' );

		// Reading voice for the export — follows the on-screen Plain/HD toggle.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
		$style = isset( $_POST['style'] ) ? sanitize_key( wp_unslash( $_POST['style'] ) ) : 'plain';
		if ( 'hd' !== $style ) {
			$style = 'plain';
		}

		$autoload = RBHDC_PLUGIN_DIR . 'vendor/autoload.php';
		if ( file_exists( $autoload ) ) {
			require_once $autoload;
		}
		if ( ! class_exists( '\Dompdf\Dompdf' ) ) {
			wp_die( esc_html__( 'The PDF engine is missing from this plugin build.', 'reloom-human-design' ) );
		}

		// Render with the chart; if it ever defeats the renderer, retry without
		// it so the readings still export.
		$pdf = self::render_pdf( $row, true, $chart_png, $style );
		if ( null === $pdf ) {
			$pdf = self::render_pdf( $row, false, '', $style );
		}
		if ( null === $pdf ) {
			wp_die( esc_html__( 'Sorry, the PDF could not be generated.', 'reloom-human-design' ) );
		}

		$base     = $row['name'] ? $row['name'] : 'human-design';
		$filename = sanitize_file_name( $base . ' - Human Design.pdf' );

		// Discard any output (stray notices/warnings, WP buffers) so the binary
		// PDF is the only thing on the wire — otherwise the download corrupts.
		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}

		nocache_headers();
		header( 'Content-Type: application/pdf' );
		header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
		header( 'Content-Length: ' . strlen( $pdf ) );
		echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary PDF payload.
		exit;
	}

	/**
	 * Validate a base64 PNG data URI (the browser-rasterised chart). Returns a
	 * clean re-encoded data URI, or '' if it is not a genuine PNG.
	 *
	 * @param mixed $raw Posted value.
	 * @return string
	 */
	private static function sanitize_png_data_uri( $raw ) {
		if ( ! is_string( $raw ) || 0 !== strpos( $raw, 'data:image/png;base64,' ) ) {
			return '';
		}
		$b64 = preg_replace( '/\s+/', '', substr( $raw, strlen( 'data:image/png;base64,' ) ) );
		if ( '' === $b64 || ! preg_match( '#^[A-Za-z0-9+/]+={0,2}$#', $b64 ) ) {
			return '';
		}
		$bin = base64_decode( $b64, true );
		// Cap at ~6MB and require the PNG magic signature.
		if ( false === $bin || strlen( $bin ) > 6 * MB_IN_BYTES || 0 !== strncmp( $bin, "\x89PNG\r\n\x1a\n", 8 ) ) {
			return '';
		}
		return 'data:image/png;base64,' . base64_encode( $bin );
	}

	/**
	 * Render a profile to a PDF byte string via Dompdf, or null on failure.
	 *
	 * @param array  $row           Profile row.
	 * @param bool   $include_chart Embed the bodygraph?
	 * @param string $chart_png     Optional pre-rasterised chart PNG data URI.
	 * @return string|null
	 */
	private static function render_pdf( array $row, $include_chart, $chart_png = '', $style = 'plain' ) {
		$ob_level = ob_get_level();
		try {
			$html = self::build_pdf_document( $row, $include_chart, $chart_png, $style );

			// Dompdf needs a writable cache dir. The plugin folder may be
			// read-only on shared hosts, so use the (always-writable) uploads
			// dir, falling back to the system temp dir.
			$upload = wp_upload_dir();
			$tmp    = ( ! empty( $upload['basedir'] ) ) ? trailingslashit( $upload['basedir'] ) . 'rbhdc-pdf' : '';
			if ( '' === $tmp || ! wp_mkdir_p( $tmp ) ) {
				$tmp = sys_get_temp_dir();
			}

			$options = new \Dompdf\Options();
			$options->set( 'isRemoteEnabled', false );
			$options->set( 'isHtml5ParserEnabled', true );
			$options->set( 'defaultFont', 'DejaVu Serif' );
			$options->set( 'tempDir', $tmp );
			$options->set( 'fontCache', $tmp );
			$options->set( 'chroot', array( RBHDC_PLUGIN_DIR, (string) ( $upload['basedir'] ?? $tmp ) ) );

			// Buffer the render so any deprecation/notice output emitted by the
			// PDF library (some versions on newer PHP) is captured and discarded
			// rather than leaking into the binary download.
			ob_start();
			$dompdf = new \Dompdf\Dompdf( $options );
			$dompdf->loadHtml( $html, 'UTF-8' );
			// Load the PDF stylesheet through Dompdf's own CSS API (author
			// origin). Dompdf's Stylesheet is created once in its constructor
			// and only appended to during render, so rules loaded here apply
			// exactly like document styles — without embedding any CSS in the
			// HTML. wp_enqueue_style() does not apply: this CSS never reaches
			// a WordPress page; it exists only inside the generated PDF.
			$dompdf->getCss()->load_css_file( RBHDC_PLUGIN_DIR . 'assets/css/pdf.css', \Dompdf\Css\Stylesheet::ORIG_AUTHOR );
			$dompdf->setPaper( 'A4', 'portrait' );
			$dompdf->render();
			$pdf = (string) $dompdf->output();
			ob_end_clean();
			return $pdf;
		} catch ( \Throwable $e ) {
			while ( ob_get_level() > $ob_level ) {
				ob_end_clean();
			}
			error_log( '[rbhdc] PDF render failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			return null;
		}
	}

	/**
	 * Fetch the branding logo and return it as a base64 data URI Dompdf can
	 * embed without a network call at render time. Only raster types Dompdf
	 * renders reliably are allowed; anything else returns '' (text fallback).
	 *
	 * @param string $url Logo URL.
	 * @return string data: URI or ''.
	 */
	private static function logo_data_uri( $url ) {
		$url = trim( (string) $url );
		if ( '' === $url ) {
			return '';
		}
		$ck     = 'rbhdc_logo_' . md5( $url );
		$cached = get_transient( $ck );
		if ( is_string( $cached ) ) {
			return $cached;
		}
		$res = wp_remote_get( $url, array( 'timeout' => 10 ) );
		$out = '';
		if ( ! is_wp_error( $res ) && 200 === (int) wp_remote_retrieve_response_code( $res ) ) {
			$body = wp_remote_retrieve_body( $res );
			$type = strtolower( (string) wp_remote_retrieve_header( $res, 'content-type' ) );
			if ( '' === $type ) {
				$ext  = strtolower( (string) pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
				$map  = array( 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif' );
				$type = isset( $map[ $ext ] ) ? $map[ $ext ] : '';
			}
			$type = trim( explode( ';', $type )[0] );
			if ( '' !== $body && in_array( $type, array( 'image/png', 'image/jpeg', 'image/gif' ), true ) ) {
				$out = 'data:' . $type . ';base64,' . base64_encode( $body );
			}
		}
		set_transient( $ck, $out, DAY_IN_SECONDS );
		return $out;
	}

	/**
	 * Assemble the PDF source document for a profile (rendered by Dompdf):
	 * cover (name, key facts, bodygraph) → readings → "Powered by" footer.
	 *
	 * @param array  $row           Profile row.
	 * @param bool   $include_chart Embed the bodygraph?
	 * @param string $chart_png     Optional pre-rasterised chart PNG data URI.
	 * @return string Full HTML document.
	 */
	private static function build_pdf_document( array $row, $include_chart = true, $chart_png = '', $style = 'plain' ) {
		$labels   = self::scope_labels();
		$chart    = self::get_chart( $row['id'] );
		$readings = self::get_readings( $row['id'] );

		// "Powered by" branding is strictly opt-in (admin checkbox in Settings,
		// off by default — WP.org guideline 10). Only when enabled do we fetch
		// the logo/URL from Reloom; fresh, so a change shows in the next export.
		$branding_on = RBHDC_Client::is_pdf_branding_on();
		$logo_data   = '';
		$brand_url   = '';
		$brand_host  = '';
		if ( $branding_on ) {
			$meta      = RBHDC_Client::meta( true );
			$logo_url  = ( is_array( $meta ) && ! empty( $meta['logo_url'] ) ) ? (string) $meta['logo_url'] : '';
			$brand_url = ( is_array( $meta ) && ! empty( $meta['brand_url'] ) ) ? (string) $meta['brand_url'] : '';
			$logo_data = '' !== $logo_url ? self::logo_data_uri( $logo_url ) : '';
			if ( '' !== $brand_url ) {
				$brand_host = (string) wp_parse_url( $brand_url, PHP_URL_HOST );
				$brand_host = preg_replace( '/^www\./', '', $brand_host );
			}
		}

		$birth_bits = array_filter(
			array(
				trim( (string) ( $row['date'] ?? '' ) . ' ' . (string) ( $row['time'] ?? '' ) ),
				(string) ( $row['place'] ?? '' ),
				(string) ( $row['timezone'] ?? '' ),
			),
			'strlen'
		);

		$data = ( $chart && ! empty( $chart['data'] ) && is_array( $chart['data'] ) ) ? $chart['data'] : array();

		// Bodygraph: prefer the browser-rasterised PNG (renders reliably in the
		// PDF engine); otherwise fall back to the raw SVG from the chart payload.
		$svg = '';
		if ( $include_chart && '' === $chart_png ) {
			foreach ( array( 'SVG', 'svg', 'chart', 'Chart' ) as $k ) {
				if ( ! empty( $data[ $k ] ) && is_string( $data[ $k ] ) && '' !== trim( $data[ $k ] ) ) {
					$svg = (string) $data[ $k ];
					break;
				}
			}
		}
		$has_chart = $include_chart && ( '' !== $chart_png || '' !== $svg );

		// Compact "key facts" band for the cover.
		$fact_keys = array(
			'Type'           => __( 'Type', 'reloom-human-design' ),
			'Strategy'       => __( 'Strategy', 'reloom-human-design' ),
			'InnerAuthority' => __( 'Authority', 'reloom-human-design' ),
			'Profile'        => __( 'Profile', 'reloom-human-design' ),
			'Definition'     => __( 'Definition', 'reloom-human-design' ),
		);
		$facts = array();
		foreach ( $fact_keys as $k => $label ) {
			$v = $data ? RBHDC_Chart_Renderer::get_property( $data, $k ) : '';
			if ( '' !== $v ) {
				$facts[ $label ] = $v;
			}
		}

		// Cover page: name → birth line → key facts → bodygraph.
		$cover  = '<div class="cover">';
		$cover .= '<h1>' . esc_html( (string) $row['name'] ) . '</h1>';
		if ( $birth_bits ) {
			$cover .= '<p class="birth">' . esc_html( implode( '   ·   ', $birth_bits ) ) . '</p>';
		}
		if ( $facts ) {
			$cover .= '<div class="facts">';
			foreach ( $facts as $label => $v ) {
				$cover .= '<div class="fact"><span class="k">' . esc_html( $label ) . '</span><span class="v">' . esc_html( $v ) . '</span></div>';
			}
			$cover .= '</div>';
		}
		if ( '' !== $chart_png ) {
			$cover .= '<div class="chart"><img src="' . esc_attr( $chart_png ) . '" alt="" /></div>';
		} elseif ( '' !== $svg ) {
			$cover .= '<div class="chart">' . wp_kses( $svg, RBHDC_Chart_Renderer::allowed_svg_tags_public() ) . '</div>';
		}
		$cover .= '</div>';

		// Readings — each on a fresh page after the cover. Cached per voice
		// ("slot|plain" / "slot|hd"; legacy un-suffixed entries predate the
		// voices feature and are plain). The export must match the on-screen
		// toggle, so when a reading exists in the OTHER voice only (e.g. the
		// user flipped the toggle and exported before every re-pull finished),
		// pull the requested voice from Reloom right here — it caches per
		// voice server-side, so a previously generated voice returns fast.
		// A time budget bounds the worst case; past it we fall back to the
		// cached other voice rather than dropping the section.
		$other        = 'hd' === $style ? 'plain' : 'hd';
		$deadline     = time() + 60; // ponytail: fixed 60s live-fetch budget; tune if hosts allow longer.
		$reading_html = '';
		// Match the on-screen tab strip: only readings in the Reloom plan. A
		// stale cache can hold readings that dropped out of the plan — the
		// screen hides them, so the PDF must too. If the scopes lookup fails
		// (offline), export whatever is cached rather than an empty document.
		$active = RBHDC_Client::active_scopes();
		$slots  = $active ? array_values( array_intersect( self::reading_slots(), $active ) ) : self::reading_slots();
		foreach ( $slots as $slot ) {
			$entry        = $readings[ $slot . '|' . $style ] ?? ( 'plain' === $style ? ( $readings[ $slot ] ?? null ) : null );
			$cached_other = $readings[ $slot . '|' . $other ] ?? $readings[ $slot ] ?? null;
			if ( ( ! $entry || empty( $entry['text'] ) ) && ! empty( $cached_other['text'] ) && time() < $deadline ) {
				// Cached in the other voice only — the scope is clearly part of
				// the plan, so fetch the requested voice now and cache it.
				$res = RBHDC_Client::reading( $slot, $row, $style );
				if ( ! is_wp_error( $res ) && ! empty( $res['markdown'] ) ) {
					self::store_reading( $row['id'], $slot . '|' . $style, (string) $res['markdown'] );
					$entry = array( 'text' => (string) $res['markdown'] );
				}
			}
			if ( ! $entry || empty( $entry['text'] ) ) {
				$entry = $cached_other; // Last resort: other voice beats a missing section.
			}
			if ( ! $entry || empty( $entry['text'] ) ) {
				continue;
			}
			$reading_html .= '<div class="reading">'
				. '<h2>' . esc_html( $labels[ $slot ] ) . '</h2>'
				. '<div class="reading-body">' . RBHDC_Chart_Renderer::markdown_to_html( (string) $entry['text'] ) . '</div>'
				. '</div>';
		}

		$body = $cover;
		if ( '' !== $reading_html ) {
			$body .= '<div class="readings' . ( $has_chart ? ' page-break' : '' ) . '">' . $reading_html . '</div>';
		}

		// Footer: "Powered by" + logo (or brand host as text), with the URL.
		// Rendered ONLY when the admin has opted in (Settings → PDF branding).
		$footer = '';
		if ( $branding_on ) {
			$footer  = '<div class="doc-foot">';
			$footer .= '<div class="pb-line"><span class="pb">' . esc_html__( 'Powered by', 'reloom-human-design' ) . '</span>';
			if ( '' !== $logo_data ) {
				$footer .= ' <img class="pb-logo" src="' . esc_attr( $logo_data ) . '" alt="" />';
			} elseif ( '' !== $brand_host ) {
				$footer .= ' <span class="pb-name">' . esc_html( $brand_host ) . '</span>';
			}
			$footer .= '</div>';
			if ( '' !== $brand_url && '' !== $brand_host && '' !== $logo_data ) {
				$footer .= '<div class="pb-url"><a href="' . esc_url( $brand_url ) . '">' . esc_html( $brand_host ) . '</a></div>';
			}
			$footer .= '</div>';
		}

		$title = sprintf(
			/* translators: %s: person name. */
			__( 'Human Design — %s', 'reloom-human-design' ),
			(string) $row['name']
		);

		$html  = '<!doctype html><html><head>';
		$html .= '<meta charset="utf-8" />';
		$html .= '<title>' . esc_html( $title ) . '</title>';
		// No stylesheet reference here: the PDF's CSS (assets/css/pdf.css) is
		// handed to the bundled Dompdf engine directly via its Stylesheet API
		// in render_pdf(). This document is Dompdf input only and is never
		// echoed to a WordPress page.
		$html .= '</head><body>';
		$html .= $body . $footer;
		$html .= '</body></html>';

		return $html;
	}


	/**
	 * City typeahead — proxies to the Suite's /locations endpoint.
	 */
	public static function ajax_locations() {
		self::guard(); // Nonce + capability checked in guard().
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		$query = isset( $_POST['query'] ) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : '';
		$res   = RBHDC_Client::locations( $query );
		if ( is_wp_error( $res ) ) {
			wp_send_json_error( array( 'message' => $res->get_error_message() ) );
		}
		wp_send_json_success( array( 'results' => $res ) );
	}

	public static function ajax_export() {
		self::guard();
		wp_send_json_success( array(
			'filename' => 'rbhdc-profiles-' . gmdate( 'Ymd' ) . '.json',
			'json'     => wp_json_encode( array( 'profiles' => self::get_all() ), JSON_PRETTY_PRINT ),
		) );
	}

	public static function ajax_import() {
		self::guard(); // Nonce + capability checked in guard().
		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by json_decode below; each profile field is sanitized in self::save().
		$raw  = isset( $_POST['json'] ) ? wp_unslash( $_POST['json'] ) : '';
		$data = json_decode( $raw, true );
		$list = is_array( $data ) && isset( $data['profiles'] ) ? $data['profiles'] : ( is_array( $data ) ? $data : null );
		if ( ! is_array( $list ) ) {
			wp_send_json_error( array( 'message' => __( 'Not a valid profiles JSON file.', 'reloom-human-design' ) ) );
		}
		$added = 0;
		foreach ( $list as $p ) {
			if ( is_array( $p ) ) {
				// json_decode() is not sanitization — each field goes through
				// the allowlist sanitizer before being stored.
				self::save( self::sanitize_profile_input( $p ) );
				$added++;
			}
		}
		wp_send_json_success( array( 'added' => $added ) );
	}
}
