<?php
/**
 * Admin menu and asset registration.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Admin;

use TextTango\Install\Activator;
use TextTango\WooCommerce\Templates;

defined( 'ABSPATH' ) || exit;

/**
 * Registers the top-level TextTango menu and loads its assets.
 */
final class Menu {

	/**
	 * Slug of the settings screen.
	 */
	public const SETTINGS_SLUG = 'texttango-settings';

	/**
	 * Nonce action shared by the screen's AJAX endpoints.
	 */
	public const AJAX_NONCE = 'texttango_admin';

	/**
	 * Hook suffixes of screens owned by the plugin.
	 *
	 * @var string[]
	 */
	private array $screens = array();

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'admin_menu', array( $this, 'add_menu' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
		add_action( 'admin_head', array( $this, 'print_menu_icon_css' ) );
	}

	/**
	 * Add the top-level menu.
	 */
	public function add_menu(): void {
		/*
		 * The top-level item opens Setup until there is nothing left to set up,
		 * and Settings afterwards. A new install otherwise lands on an empty
		 * token field and three em-dashes, with the one screen that would help
		 * sitting third in the menu and nothing pointing at it.
		 *
		 * Deliberately the menu rather than a redirect. This plugin had a
		 * takeover on that screen once and it was removed for reasons that still
		 * hold — a redirect has to answer when it stops applying and how somebody
		 * gets past it, and pointing a link somewhere asks neither question.
		 */
		$landing = FirstRun::landing_slug( Wizard::is_complete() );

		/*
		 * The parent takes the landing screen's own callback, not a wrapper that
		 * looks the slug up again. A submenu whose slug equals its parent's gets
		 * the same hook name core gave the parent, so both registrations land on
		 * one hook — core relies on add_action() deduplicating them, and that only
		 * happens when the callback is identical. A wrapper here is a different
		 * callable, so nothing is deduplicated and the screen renders twice.
		 */
		$hook = add_menu_page(
			__( 'TextTango', 'texttango' ),
			__( 'TextTango', 'texttango' ),
			Activator::CAPABILITY,
			$landing,
			array( $this, self::callback_for( $landing ) ),
			/*
			 * 'none' tells core to render no image and leave the styling to CSS —
			 * see `wp-admin/menu-header.php`. Anything else and core writes an
			 * inline `background-image`, which the mask in menu_icon_css() would
			 * then have to fight with `!important`.
			 */
			'none',
			58
		);

		if ( is_string( $hook ) ) {
			$this->screens[] = $hook;
		}

		/*
		 * Setup comes first while it is the thing to do, and drops to the bottom
		 * once it is done — where somebody looking for it to check on receipts or
		 * the balance still finds it.
		 */
		$pages = Wizard::is_complete()
			? array( 'settings', 'analytics', 'audience', 'campaigns', 'setup' )
			: array( 'setup', 'settings', 'analytics', 'audience', 'campaigns' );

		foreach ( $pages as $page ) {
			$added = $this->add_page( $page, $landing );

			if ( is_string( $added ) ) {
				$this->screens[] = $added;
			}
		}
	}

	/**
	 * Add one submenu page.
	 *
	 * @param string $page    One of `settings`, `analytics`, `audience`, `campaigns`, `setup`.
	 * @param string $landing The parent slug, which is also one of their slugs.
	 * @return string|false Hook suffix, or false.
	 */
	private function add_page( string $page, string $landing ) {
		$labels = array(
			self::SETTINGS_SLUG => __( 'Settings', 'texttango' ),
			AnalyticsPage::SLUG => __( 'Analytics', 'texttango' ),
			AudiencePage::SLUG  => __( 'Audience', 'texttango' ),
			CampaignsPage::SLUG => __( 'Campaigns', 'texttango' ),
			Wizard::SLUG        => __( 'Setup', 'texttango' ),
		);

		$slug  = self::slug_for( $page );
		$label = $labels[ $slug ];

		return add_submenu_page(
			$landing,
			$label,
			$label,
			Activator::CAPABILITY,
			$slug,
			// Identical to the parent's callable when this is the landing page,
			// which is what lets core deduplicate the two registrations.
			array( $this, self::callback_for( $slug ) )
		);
	}

	/**
	 * The slug of one of the plugin's screens.
	 *
	 * @param string $page One of `settings`, `analytics`, `audience`, `campaigns`, `setup`.
	 */
	private static function slug_for( string $page ): string {
		$slugs = array(
			'settings'  => self::SETTINGS_SLUG,
			'analytics' => AnalyticsPage::SLUG,
			'audience'  => AudiencePage::SLUG,
			'campaigns' => CampaignsPage::SLUG,
			'setup'     => Wizard::SLUG,
		);

		return $slugs[ $page ];
	}

	/**
	 * The method that renders a given slug.
	 *
	 * One table, so the parent and the submenu sharing a slug cannot end up with
	 * two different callables — the failure that renders a screen twice.
	 *
	 * @param string $slug A screen slug.
	 */
	private static function callback_for( string $slug ): string {
		$callbacks = array(
			self::SETTINGS_SLUG => 'render_settings',
			AnalyticsPage::SLUG => 'render_analytics',
			AudiencePage::SLUG  => 'render_audience',
			CampaignsPage::SLUG => 'render_campaigns',
			Wizard::SLUG        => 'render_wizard',
		);

		return $callbacks[ $slug ];
	}

	/**
	 * The brand mark, as a data URI used for a CSS mask.
	 *
	 * White, and that is not a colour choice — a mask discards colour entirely.
	 * A mask may be read as alpha or as luminance depending on the property and
	 * the engine, and white is both fully opaque and full luminance, so it is
	 * correct under either reading. The visible colour comes from
	 * {@see self::menu_icon_css()}, which takes it from WordPress.
	 *
	 * The geometry is the brand mark traced from `android-chrome-512x512.png`:
	 * two rings, the square above the left one, and the cup below it. It lives
	 * here rather than in a file so there is exactly one copy of it.
	 */
	public static function menu_icon(): string {
		$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">'
			. '<circle cx="121.5" cy="209" r="92" fill="none" stroke="#fff" stroke-width="60"/>'
			. '<circle cx="391" cy="207" r="91.5" fill="none" stroke="#fff" stroke-width="59"/>'
			. '<rect x="192" y="29" width="60" height="60" fill="#fff"/>'
			. '<path d="M-0.5 361A122 122 0 0 0 243.5 361L183.5 361A62 62 0 0 1 59.5 361Z" fill="#fff"/>'
			. '</svg>';

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding a literal defined three lines above, which is the form a CSS data URI takes.
		return 'data:image/svg+xml;base64,' . base64_encode( $svg );
	}

	/**
	 * The rule that paints the menu icon.
	 *
	 * ### Why a mask rather than an image
	 *
	 * WordPress colours a dashicon through the `:before` pseudo-element, never
	 * through the image — `admin-menu.css` sets `rgba(240,246,252,.6)` at rest,
	 * `#fff` when the item is current, and a transition between them, all on
	 * `div.wp-menu-image:before`. An icon supplied as a background image gets
	 * none of that and stays whatever colour it was drawn in, which is why the
	 * first version of this was the only coloured thing in the menu.
	 *
	 * Painting the mark as a mask over `currentColor` on that same pseudo-element
	 * inherits the lot: the resting grey, the white when current, the hover, the
	 * fade, and every one of the eight admin colour schemes. None of that state
	 * machine is reimplemented here, so none of it can fall out of step.
	 *
	 * The `@supports` guard matters. Without it, an engine with no mask support
	 * would paint `currentColor` across the whole block and show a solid square;
	 * with it, such a browser shows nothing, which is what the `'none'` icon
	 * passed to `add_menu_page()` means anyway.
	 */
	public static function menu_icon_css(): string {
		return sprintf(
			'@supports (mask-image: url("")) or (-webkit-mask-image: url("")) {
				#adminmenu #toplevel_page_%1$s div.wp-menu-image:before {
					content: "";
					display: block;
					width: 20px;
					height: 20px;
					margin: 7px auto;
					padding: 0;
					background-color: currentColor;
					-webkit-mask: url("%2$s") no-repeat center / 20px 20px;
					mask: url("%2$s") no-repeat center / 20px 20px;
				}
			}',
			self::SETTINGS_SLUG,
			self::menu_icon()
		);
	}

	/**
	 * Print the menu icon rule.
	 *
	 * Inline, and on every admin page, because the menu is on every admin page
	 * and this is a few hundred bytes. The plugin's stylesheet loads only on its
	 * own screens, so it is the wrong home for something the whole admin needs.
	 */
	public function print_menu_icon_css(): void {
		printf( "<style id='texttango-menu-icon'>%s</style>\n", self::menu_icon_css() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- A CSS literal built from a class constant and a data URI this class generates.
	}

	/**
	 * Render the analytics screen.
	 */
	public function render_analytics(): void {
		( new AnalyticsPage() )->render();
	}

	/**
	 * Render the audience screen.
	 */
	public function render_audience(): void {
		( new AudiencePage() )->render();
	}

	/**
	 * Render the campaigns screen.
	 */
	public function render_campaigns(): void {
		( new CampaignsPage() )->render();
	}

	/**
	 * Render the setup checklist.
	 */
	public function render_wizard(): void {
		( new Wizard() )->render();
	}

	/**
	 * Render the settings screen.
	 */
	public function render_settings(): void {
		( new SettingsPage() )->render();
	}

	/**
	 * Enqueue the plugin's compiled stylesheet and script, on our screens only.
	 *
	 * @param string $hook_suffix Current admin screen.
	 */
	public function enqueue_assets( string $hook_suffix ): void {
		if ( ! in_array( $hook_suffix, $this->screens, true ) ) {
			return;
		}

		self::enqueue_plugin_assets( self::AJAX_NONCE );

		/*
		 * WooCommerce's own searchable product field, on the campaigns screen
		 * only. A plain select cannot hold a real catalogue, and this is the
		 * control merchants already know from every other WooCommerce screen —
		 * borrowing it is cheaper and more familiar than building a worse one.
		 */
		if ( str_contains( $hook_suffix, CampaignsPage::SLUG ) && wp_script_is( 'wc-enhanced-select', 'registered' ) ) {
			wp_enqueue_script( 'wc-enhanced-select' );
			wp_enqueue_style( 'woocommerce_admin_styles' );
		}
	}

	/**
	 * Load the admin bundle and its configuration.
	 *
	 * Separate from the screen check so other admin surfaces — the profile
	 * screen's phone-verification widget, for one — can reuse the same bundle
	 * with a nonce scoped to their own endpoints.
	 *
	 * @param string $nonce_action Nonce action the page's endpoints expect.
	 */
	public static function enqueue_plugin_assets( string $nonce_action ): void {
		wp_enqueue_style(
			'texttango-admin',
			TEXTTANGO_URL . 'assets/admin/admin.css',
			array(),
			TEXTTANGO_VERSION
		);

		wp_enqueue_script(
			'texttango-admin',
			TEXTTANGO_URL . 'assets/admin/admin.js',
			array( 'wp-i18n' ),
			TEXTTANGO_VERSION,
			true
		);

		/*
		 * Only a nonce and display strings cross into JavaScript. No token, no
		 * webhook secret, no decrypted value ever appears here (spec §12, §13).
		 */
		wp_localize_script(
			'texttango-admin',
			'textTangoAdmin',
			array(
				'ajaxUrl'      => admin_url( 'admin-ajax.php' ),
				'nonce'        => wp_create_nonce( $nonce_action ),
				// Shared with the segment counter in the template editor, so the
				// browser and the server agree on which characters are cheap.
				'charset'      => Templates::charset_config(),
				'sampleValues' => Templates::sample_values(),
				'i18n'         => array(
					// This one is substituted in JavaScript rather than by printf(),
					// so it is handed over with its placeholders intact. The note is
					// here and not in the translator comment below: the same string
					// is translated in two templates too, and a comment that differs
					// between copies reaches the translator as contradictory advice
					// about one entry.
					/* translators: 1: character count, 2: encoding name, 3: number of SMS segments. */
					'estimate'         => __( '%1$d characters, %2$s encoding, %3$d SMS', 'texttango' ),
					'customised'       => __( 'Customised', 'texttango' ),
					'usingDefault'     => __( 'Default', 'texttango' ),
					'testing'          => __( 'Testing…', 'texttango' ),
					'checking'         => __( 'Checking…', 'texttango' ),
					'sending'          => __( 'Sending…', 'texttango' ),
					'testConnection'   => __( 'Test connection', 'texttango' ),
					'sendTestSms'      => __( 'Send a test SMS', 'texttango' ),
					'genericError'     => __( 'Something went wrong. Please try again.', 'texttango' ),
					'confirmRealSend'  => __( 'This sends a real SMS through TextTango and uses credits from your balance, even though sandbox mode is on. Continue?', 'texttango' ),
					'confirmTestSend'  => __( 'This sends a real SMS through TextTango and uses credits from your balance. Continue?', 'texttango' ),
					'missingTestPhone' => __( 'Enter the phone number to send the test to.', 'texttango' ),
					'needPhone'        => __( 'Enter a phone number.', 'texttango' ),
					'needCode'         => __( 'Enter the code we sent you.', 'texttango' ),
					'verifying'        => __( 'Verifying…', 'texttango' ),
					'confirmAttach'    => __( 'This sends a real SMS to that number and uses credits from your balance. Continue?', 'texttango' ),
					'confirmResend'    => __( 'This sends the same message again and uses credits from your balance. Continue?', 'texttango' ),

					// Delivery-receipt setup (spec §20).
					'working'          => __( 'Working…', 'texttango' ),
					'waiting'          => __( 'Waiting…', 'texttango' ),
					'webhookWaiting'   => __( 'Sent. Waiting for it to arrive here…', 'texttango' ),
					'webhookArrived'   => __( 'It arrived and the signature verified. Delivery receipts are working.', 'texttango' ),
					'webhookNever'     => __( 'Nothing arrived. Check that the signing secret matches, and that nothing is blocking the REST route.', 'texttango' ),
					'webhookElsewhere' => __( 'Registered — but this token has a campaign callback URL pointing somewhere else, and TextTango sends test events there instead. The test below will not reach this site until that is cleared.', 'texttango' ),
				),
			)
		);
	}
}
