<?php
/**
 * Connection settings screen.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Admin;

use TextTango\Api\Client;
use TextTango\Api\PhoneNumber;
use TextTango\Audit\AuditLog;
use TextTango\Consent\CheckoutConsent;
use TextTango\Consent\ConsentList;
use TextTango\Consent\DoubleOptIn;
use TextTango\Consent\QuietHours;
use TextTango\Consent\Suppressions;
use TextTango\Forms\FormSettings;
use TextTango\Forms\Forms;
use TextTango\Contacts\ContactReconciler;
use TextTango\Install\Activator;
use TextTango\Install\Importer;
use TextTango\Marketing\AbandonedCart;
use TextTango\Messages\LogFilter;
use TextTango\Messages\MessageLog;
use TextTango\Otp\LoginForm;
use TextTango\Otp\UserRepository;
use TextTango\Support\Paging;
use TextTango\Sending\BalanceWatch;
use TextTango\Sending\Budget;
use TextTango\Sending\CircuitBreaker;
use TextTango\Support\Crypto;
use TextTango\Support\Sandbox;
use TextTango\Support\Settings;
use TextTango\Webhook\Route;
use TextTango\Webhook\WebhookState;
use TextTango\WooCommerce\Events;
use TextTango\WooCommerce\Notifications;
use TextTango\WooCommerce\Templates;

defined( 'ABSPATH' ) || exit;

/**
 * Renders and saves the connection settings (spec §4).
 */
final class SettingsPage {

	/**
	 * `admin_post` action for the save handler.
	 */
	public const SAVE_ACTION = 'texttango_save_settings';

	/**
	 * Query argument naming the audit trail's page.
	 *
	 * One argument per table rather than a shared `paged`, because two tables
	 * share the Logs screen and two share Consent — a single argument would page
	 * both of them on a screen at once.
	 */
	public const AUDIT_PAGE = 'audit_page';

	/**
	 * Query argument naming the opt-out list's page.
	 */
	public const SUPPRESSED_PAGE = 'suppressed_page';

	/**
	 * Query argument naming the consent list's page.
	 */
	public const CONSENT_PAGE = 'consent_page';

	/**
	 * `admin_post` action that downloads the settings as JSON.
	 */
	public const EXPORT_ACTION = 'texttango_export_settings';

	/**
	 * Nonce action for the save handler.
	 */
	public const SAVE_NONCE = 'texttango_save_settings';

	/**
	 * Where an admin creates an API token, since the plugin cannot mint one (spec §4).
	 *
	 * No `/v2` segment: that is a route *name* prefix on the dashboard, not part of
	 * the path, so including it gave a 404 to everyone who followed the one link
	 * this screen offers at the point they have nothing else to go on.
	 */
	public const TOKEN_PAGE = 'https://app.texttango.com/profile?tab=api-tokens';

	/**
	 * Abilities the token needs for the features shipped so far.
	 *
	 * @var string[]
	 */
	public const REQUIRED_ABILITIES = array(
		'sms:send',
		'account:read',
		'webhooks:read',
		'webhooks:write',
	);

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'admin_post_' . self::SAVE_ACTION, array( $this, 'handle_save' ) );
		add_action( 'admin_post_' . self::EXPORT_ACTION, array( $this, 'handle_export' ) );
	}

	/**
	 * Output the screen.
	 */
	public function render(): void {
		if ( ! current_user_can( Activator::CAPABILITY ) ) {
			wp_die( esc_html__( 'You do not have permission to manage TextTango settings.', 'texttango' ) );
		}

		$tab = Tabs::current();

		/*
		 * Built once and only on the Logs tab. It costs a `DISTINCT event` query
		 * to know what the filter may offer, and every other tab would pay for
		 * a control it does not render.
		 */
		$log_filter = null;
		$log_events = array();
		$log_total  = 0;

		if ( Tabs::LOGS === $tab ) {
			$log_events = MessageLog::events_seen();

			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view state, validated against the status and event registries by LogFilter.
			$log_filter = LogFilter::from_request( wp_unslash( $_GET ), self::log_statuses(), $log_events );
			$log_total  = MessageLog::count_matching( $log_filter );

			// Counting first is what makes this possible: the page cannot be
			// bounded until we know how many pages there are.
			$log_filter = $log_filter->clamp( $log_total );
		}

		/*
		 * The other four tables. Each carries its own page argument, because the
		 * Logs screen shows the message log beside the audit trail and the Consent
		 * screen the opt-out list beside the consent list — one shared argument
		 * would page both tables on a screen at once.
		 *
		 * Every one is counted before it is read, so the page can be clamped: a
		 * page past the end returns nothing, and an empty opt-out list reads as
		 * "nobody has asked to be left alone", which is the opposite of the truth.
		 */
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only view state; Paging validates it to a positive integer.
		$query = wp_unslash( $_GET );
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$audit_total  = Tabs::LOGS === $tab ? AuditLog::count() : 0;
		$audit_paging = Paging::from_request( $query, self::AUDIT_PAGE )->clamp( $audit_total );

		$suppressed_total  = Tabs::CONSENT === $tab ? Suppressions::count() : 0;
		$suppressed_paging = Paging::from_request( $query, self::SUPPRESSED_PAGE )->clamp( $suppressed_total );

		$consents_total  = Tabs::CONSENT === $tab ? ConsentList::count() : 0;
		$consents_paging = Paging::from_request( $query, self::CONSENT_PAGE )->clamp( $consents_total );

		$form_providers = Tabs::FORMS === $tab ? self::form_providers() : array();

		$view = array(
			'tab'                   => $tab,
			'form_providers'        => $form_providers,
			'form_config'           => Tabs::FORMS === $tab ? FormSettings::all() : array(),
			'tabs'                  => Tabs::all(),
			'tab_rows'              => Tabs::rows( $tab ),
			'tab_template'          => Tabs::template( $tab ),
			'save_nonce'            => Tabs::nonce_action( $tab ),
			'settings'              => Settings::all(),
			'has_api_key'           => Settings::secret_exists( Settings::OPTION_API_KEY ),
			'has_webhook'           => Settings::secret_exists( Settings::OPTION_WEBHOOK_SECRET ),
			'unreadable'            => Settings::has_unreadable_secrets(),
			'derived_key'           => Crypto::uses_derived_key(),
			'sodium'                => Crypto::is_available(),
			'connection'            => Settings::connection_state(),
			'balance'               => BalanceWatch::last(),
			'notices'               => Notices::take(),
			'webhook_url'           => Route::url(),
			'webhook_verified'      => WebhookState::is_verified(),
			'webhook_last_arrival'  => WebhookState::last_arrival(),
			'token_page'            => self::TOKEN_PAGE,
			'abilities'             => self::REQUIRED_ABILITIES,
			'save_action'           => self::SAVE_ACTION,
			'export_action'         => self::EXPORT_ACTION,
			'export_nonce'          => self::EXPORT_ACTION,
			'countries'             => self::countries(),
			'sandbox_env_note'      => wp_get_environment_type(),
			'sandbox_active'        => Sandbox::is_active(),
			'sandbox_auto'          => Sandbox::is_automatic(),
			'sandbox_summary'       => Sandbox::describe(),
			'roles'                 => self::roles(),
			'otp_shortcode'         => '[' . LoginForm::SHORTCODE . ']',
			'otp_default_tpl'       => __( '{otp_code} is your {store_name} verification code. It expires in {minutes} minutes.', 'texttango' ),
			'log_rows'              => $log_filter instanceof LogFilter ? MessageLog::search( $log_filter ) : array(),
			'log_filter'            => $log_filter,
			'log_events'            => $log_events,
			'log_total'             => $log_total,
			'log_pages'             => LogFilter::pages( $log_total ),
			'audit_rows'            => Tabs::LOGS === $tab ? AuditLog::paged( $audit_paging ) : array(),
			'audit_paging'          => $audit_paging,
			'audit_total'           => $audit_total,
			'suppressions'          => Tabs::CONSENT === $tab ? Suppressions::paged( $suppressed_paging ) : array(),
			'suppressed_paging'     => $suppressed_paging,
			'suppression_count'     => $suppressed_total,
			'consent_list'          => Tabs::CONSENT === $tab ? ConsentList::paged( $consents_paging ) : array(),
			'consent_paging'        => $consents_paging,
			'consent_list_count'    => $consents_total,
			'consent_default_label' => CheckoutConsent::label(),
			'consent_default_tpl'   => DoubleOptIn::default_template(),
			'reconcile_report'      => Tabs::CONSENT === $tab ? ContactReconciler::report() : array(),
			'usage'                 => Tabs::ADVANCED === $tab ? Budget::usage() : array(),
			'import_sources'        => Tabs::ADVANCED === $tab ? Importer::sources() : array(),
			'balance_seen'          => BalanceWatch::formatted(),
			'circuit'               => CircuitBreaker::state(),
			'site_timezone'         => QuietHours::timezone(),
			'log_statuses'          => self::log_statuses(),
			'events'                => Events::all(),
			'event_settings'        => Events::settings(),
			'merge_tags'            => Templates::tags(),
			'cart_tags'             => Templates::cart_tags(),
			'woo_active'            => Notifications::woocommerce_active(),
		);

		load_template( TEXTTANGO_PATH . 'templates/admin/settings.php', false, $view );
	}

	/**
	 * Handle the settings form submission.
	 */
	public function handle_save(): void {
		if ( ! current_user_can( Activator::CAPABILITY ) ) {
			wp_die( esc_html__( 'You do not have permission to manage TextTango settings.', 'texttango' ) );
		}

		// The tab decides which nonce to verify, so it is read first and validated
		// against the registry; `check_admin_referer()` follows immediately.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified on the next statement.
		$tab = Tabs::from_request( isset( $_POST['texttango_tab'] ) ? sanitize_key( wp_unslash( $_POST['texttango_tab'] ) ) : '' );

		check_admin_referer( Tabs::nonce_action( $tab ) );

		/*
		 * Only the submitted tab's saver runs. Every checkbox on this screen is
		 * read as `isset( $_POST['x'] )`, so a field the browser never rendered
		 * looks identical to one the admin unticked. Running every saver on every
		 * submit — as this used to — would mean saving Connection silently
		 * switched off OTP login, two-factor, and the uninstall guard.
		 */
		switch ( $tab ) {
			case Tabs::NOTIFICATIONS:
				$this->save_notifications();
				break;
			case Tabs::TESTING:
				$this->save_testing();
				break;
			case Tabs::SIGNIN:
				$this->save_signin();
				break;
			case Tabs::ADVANCED:
				$this->save_advanced();
				break;
			case Tabs::CONSENT:
				$this->save_consent();
				break;
			case Tabs::MARKETING:
				$this->save_marketing();
				break;
			case Tabs::FORMS:
				$this->save_forms();
				break;
			case Tabs::LOGS:
				// Read-only screen; nothing to save.
				break;
			case Tabs::CONNECTION:
			default:
				$this->save_connection();
				break;
		}

		Notices::add( 'ok', __( 'Settings saved.', 'texttango' ) );

		wp_safe_redirect( Tabs::url( $tab ) );
		exit;
	}

	/**
	 * Every active form plugin, its forms, and each form's fields.
	 *
	 * Built only for the tab that renders it: this asks every installed form
	 * plugin for every form and then every field, which is several queries per
	 * form and of no use to any other screen.
	 *
	 * @return array<int, array{slug: string, label: string, forms: array<int, array{id: string, title: string, fields: string[]}>}>
	 */
	private static function form_providers(): array {
		$providers = array();

		foreach ( Forms::active_providers() as $slug => $provider ) {
			$forms = array();

			foreach ( $provider::forms() as $id => $title ) {
				$forms[] = array(
					'id'     => (string) $id,
					'title'  => (string) $title,
					'fields' => $provider::fields( (string) $id ),
				);
			}

			$providers[] = array(
				'slug'  => (string) $slug,
				'label' => $provider::label(),
				'forms' => $forms,
			);
		}

		return $providers;
	}

	/**
	 * Save the Forms tab.
	 *
	 * Writes its own option rather than settings keys, so it does not go through
	 * `Tabs::restrict()` — which is why the shape is rebuilt here from the known
	 * settings rather than trusting what arrived. A hand-made POST can name any
	 * form key it likes; it cannot introduce a setting that does not exist, and
	 * `FormSettings::normalise()` drops anything it does not recognise.
	 */
	private function save_forms(): void {
		/*
		 * Taken as a whole array and sanitised leaf by leaf in the loop below,
		 * because there is no single sanitiser for a nested structure — each
		 * value has its own kind. Nothing from this array reaches storage
		 * except through the seven named settings assembled underneath.
		 */
		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in handle_save(); every leaf is sanitised below.
		$posted = isset( $_POST['forms'] ) ? wp_unslash( $_POST['forms'] ) : array();

		if ( ! is_array( $posted ) ) {
			$posted = array();
		}

		$forms = array();

		foreach ( $posted as $key => $config ) {
			if ( ! is_string( $key ) || ! is_array( $config ) ) {
				continue;
			}

			$forms[ sanitize_text_field( $key ) ] = array(
				'enabled'        => isset( $config['enabled'] ),
				'alert'          => isset( $config['alert'] ),
				'reply'          => isset( $config['reply'] ),
				'phone_field'    => sanitize_text_field( (string) ( $config['phone_field'] ?? '' ) ),
				'consent_field'  => sanitize_text_field( (string) ( $config['consent_field'] ?? '' ) ),
				'alert_template' => sanitize_textarea_field( (string) ( $config['alert_template'] ?? '' ) ),
				'reply_template' => sanitize_textarea_field( (string) ( $config['reply_template'] ?? '' ) ),
			);
		}

		FormSettings::save( $forms );
	}

	/**
	 * Save the Marketing tab.
	 */
	private function save_marketing(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$enabled  = isset( $_POST['abandoned_cart_enabled'] );
		$delay    = isset( $_POST['abandoned_cart_delay'] ) ? absint( wp_unslash( $_POST['abandoned_cart_delay'] ) ) : 60;
		$hours    = isset( $_POST['abandoned_cart_followup_hours'] ) ? absint( wp_unslash( $_POST['abandoned_cart_followup_hours'] ) ) : 0;
		$first    = isset( $_POST['abandoned_cart_template'] )
			? sanitize_textarea_field( wp_unslash( $_POST['abandoned_cart_template'] ) )
			: '';
		$followup = isset( $_POST['abandoned_cart_followup_template'] )
			? sanitize_textarea_field( wp_unslash( $_POST['abandoned_cart_followup_template'] ) )
			: '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		Settings::update(
			array(
				'abandoned_cart_enabled'           => $enabled,
				// A week is the ceiling, and one minute the floor. Zero would mean
				// the reminder fires on the same request that filled the basket.
				'abandoned_cart_delay'             => min( 10080, max( 1, $delay ) ),
				'abandoned_cart_followup_hours'    => min( 336, $hours ),
				'abandoned_cart_template'          => self::store_template( $first, AbandonedCart::default_template( AbandonedCart::STAGE_FIRST ) ),
				'abandoned_cart_followup_template' => self::store_template( $followup, AbandonedCart::default_template( AbandonedCart::STAGE_FOLLOWUP ) ),
			)
		);
	}

	/**
	 * A template as it should be stored.
	 *
	 * Kept empty while it still matches the default, so improving the default
	 * wording later reaches everyone who never wrote their own.
	 *
	 * @param string $written The merchant's text.
	 * @param string $default_text The default it is compared against.
	 */
	private static function store_template( string $written, string $default_text ): string {
		$written = trim( $written );

		return $default_text === $written ? '' : $written;
	}

	/**
	 * Send the settings back as a JSON download.
	 */
	public function handle_export(): void {
		if ( ! current_user_can( Activator::CAPABILITY ) ) {
			wp_die( esc_html__( 'You do not have permission to manage TextTango settings.', 'texttango' ) );
		}

		check_admin_referer( self::EXPORT_ACTION );

		nocache_headers();
		header( 'Content-Type: application/json; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename=' . Portable::filename() );

		// Not escaped for HTML: this is a downloaded JSON file, not a page, and
		// wp_json_encode already produces safe JSON.
		echo wp_json_encode( Portable::export(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

		exit;
	}

	/**
	 * Apply a pasted settings file.
	 */
	private function save_import(): void {
		/*
		 * Read raw. A text sanitiser run across JSON mangles legitimate template
		 * content — a message body may hold any character — while doing nothing
		 * about the real risk, which is structural. Portable::parse() decodes it,
		 * keeps only known keys, and forces each value to the type its default
		 * declares, so nothing unvalidated reaches storage.
		 */
		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in handle_save(); validated and sanitized per value in Portable::parse().
		$json = isset( $_POST['texttango_import'] ) ? trim( (string) wp_unslash( $_POST['texttango_import'] ) ) : '';

		if ( '' === $json ) {
			return;
		}

		$parsed = Portable::parse( $json );

		if ( is_wp_error( $parsed ) ) {
			Notices::add( 'error', $parsed->get_error_message() );

			return;
		}

		$changed = Portable::apply( $parsed );

		if ( 0 === $changed ) {
			Notices::add( 'ok', __( 'That file matches this site already, so nothing changed.', 'texttango' ) );

			return;
		}

		Notices::add(
			'ok',
			sprintf(
				/* translators: %d: number of settings changed. */
				_n( 'Imported: %d setting changed.', 'Imported: %d settings changed.', $changed, 'texttango' ),
				$changed
			)
		);
	}

	/**
	 * Persist settings, refusing anything the tab does not own.
	 *
	 * @param string               $tab    Tab being saved.
	 * @param array<string, mixed> $values Candidate settings.
	 */
	private function persist( string $tab, array $values ): void {
		Settings::update( Tabs::restrict( $tab, $values ) );
	}

	/**
	 * Save the Connection tab: base URL, credentials, sender ID, country.
	 */
	private function save_connection(): void {
		$this->save_base_url();

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$sender  = isset( $_POST['sender_id'] ) ? sanitize_text_field( wp_unslash( $_POST['sender_id'] ) ) : '';
		$country = isset( $_POST['default_country'] ) ? sanitize_text_field( wp_unslash( $_POST['default_country'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		// Sender IDs are alphanumeric and at most 11 characters (spec §4).
		$sender  = substr( preg_replace( '/[^A-Za-z0-9 _.-]/', '', $sender ) ?? '', 0, 11 );
		$country = strtoupper( substr( preg_replace( '/[^A-Za-z]/', '', $country ) ?? '', 0, 2 ) );

		if ( 2 !== strlen( $country ) ) {
			$country = (string) Settings::get( 'default_country' );
		}

		$this->persist(
			Tabs::CONNECTION,
			array(
				'sender_id'       => trim( $sender ),
				'default_country' => $country,
			)
		);

		$this->save_secret(
			Settings::OPTION_API_KEY,
			'api_key',
			'remove_api_key',
			__( 'API token saved.', 'texttango' ),
			__( 'API token removed.', 'texttango' ),
			__( 'The API token could not be encrypted, so it was not saved. Check that the PHP sodium extension is enabled.', 'texttango' )
		);
		$this->save_secret(
			Settings::OPTION_WEBHOOK_SECRET,
			'webhook_secret',
			'remove_webhook_secret',
			__( 'Webhook signing secret saved.', 'texttango' ),
			__( 'Webhook signing secret removed.', 'texttango' ),
			__( 'The webhook signing secret could not be encrypted, so it was not saved. Check that the PHP sodium extension is enabled.', 'texttango' )
		);
	}

	/**
	 * Save the Testing tab: sandbox mode, behaviour, and test number.
	 */
	private function save_testing(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$mode     = isset( $_POST['sandbox_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['sandbox_mode'] ) ) : '';
		$behavior = isset( $_POST['sandbox_behavior'] ) ? sanitize_text_field( wp_unslash( $_POST['sandbox_behavior'] ) ) : '';
		$test_num = isset( $_POST['sandbox_test_number'] ) ? sanitize_text_field( wp_unslash( $_POST['sandbox_test_number'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( ! in_array( $mode, array( Sandbox::MODE_AUTO, Sandbox::MODE_ON, Sandbox::MODE_OFF ), true ) ) {
			$mode = Sandbox::MODE_AUTO;
		}

		if ( ! in_array( $behavior, array( Sandbox::BEHAVIOR_LOG_ONLY, Sandbox::BEHAVIOR_REDIRECT ), true ) ) {
			$behavior = Sandbox::BEHAVIOR_LOG_ONLY;
		}

		$test_num = $this->normalized_or_warn(
			$test_num,
			__( 'The sandbox test number could not be read as a phone number, so it was not saved.', 'texttango' )
		);

		if ( Sandbox::MODE_OFF === $mode && 'production' !== wp_get_environment_type() ) {
			Notices::add(
				'warn',
				__( 'Sandbox is switched off on a non-production site. Every send from here on is real and uses credits.', 'texttango' )
			);
		}

		$this->persist(
			Tabs::TESTING,
			array(
				'sandbox_mode'        => $mode,
				'sandbox_behavior'    => $behavior,
				'sandbox_test_number' => $test_num,
			)
		);
	}

	/**
	 * Save the Notifications tab: per-event toggles, templates, staff numbers.
	 */
	private function save_notifications(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$enabled   = isset( $_POST['event_enabled'] ) && is_array( $_POST['event_enabled'] )
			? array_map( 'sanitize_key', wp_unslash( $_POST['event_enabled'] ) )
			: array();
		$templates = isset( $_POST['event_template'] ) && is_array( $_POST['event_template'] )
			? array_map( 'sanitize_textarea_field', wp_unslash( $_POST['event_template'] ) )
			: array();
		$staff     = isset( $_POST['admin_recipients'] )
			? sanitize_textarea_field( wp_unslash( $_POST['admin_recipients'] ) )
			: '';
		$notes     = isset( $_POST['order_notes_enabled'] );
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$enabled = array_flip( $enabled );
		$stored  = array();

		// Only known events are stored, so a crafted field cannot add one.
		foreach ( array_keys( Events::all() ) as $event ) {
			$stored[ $event ] = array(
				'enabled'  => isset( $enabled[ $event ] ),
				// A message still matching the shipped default stores nothing, so a
				// later fix to that default reaches sites that never customised it.
				'template' => Events::normalize_template( $event, (string) ( $templates[ $event ] ?? '' ) ),
			);
		}

		$this->persist(
			Tabs::NOTIFICATIONS,
			array(
				'notifications'       => $stored,
				'admin_recipients'    => $this->normalized_recipients( $staff ),
				'order_notes_enabled' => $notes,
			)
		);
	}

	/**
	 * Normalize the staff numbers, warning about any that cannot be read.
	 *
	 * Kept rather than silently dropped so a typo is visible on the screen
	 * instead of quietly meaning nobody gets alerted.
	 *
	 * @param string $raw Submitted list.
	 */
	private function normalized_recipients( string $raw ): string {
		$parts   = preg_split( '/[,\n]+/', $raw );
		$numbers = array_filter( array_map( 'trim', is_array( $parts ) ? $parts : array() ) );
		$clean   = array();
		$bad     = array();

		foreach ( $numbers as $number ) {
			$normalized = PhoneNumber::normalize( $number );

			if ( is_wp_error( $normalized ) ) {
				$bad[]   = $number;
				$clean[] = $number;
				continue;
			}

			$clean[] = $normalized;
		}

		if ( ! empty( $bad ) ) {
			Notices::add(
				'warn',
				sprintf(
					/* translators: %s: comma-separated list of unusable numbers. */
					__( 'These staff numbers could not be read as phone numbers and will be skipped: %s', 'texttango' ),
					implode( ', ', $bad )
				)
			);
		}

		return implode( "\n", array_unique( $clean ) );
	}

	/**
	 * Save the Consent tab.
	 */
	private function save_consent(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$enabled = isset( $_POST['consent_checkout_enabled'] );
		$label   = isset( $_POST['consent_checkout_label'] )
			? sanitize_text_field( wp_unslash( $_POST['consent_checkout_label'] ) )
			: '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$confirm  = isset( $_POST['consent_double_optin'] );
		$template = isset( $_POST['consent_confirm_template'] )
			? sanitize_textarea_field( wp_unslash( $_POST['consent_confirm_template'] ) )
			: '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$template = trim( $template );

		// Stored empty when it still matches the default, so improving the default
		// wording later reaches everyone who never wrote their own.
		if ( DoubleOptIn::default_template() === $template ) {
			$template = '';
		}

		// A confirmation with no way to confirm would leave every new opt-in stuck
		// at pending for ever, which looks exactly like the plugin being broken.
		if ( $confirm && '' !== $template && ! str_contains( $template, '{confirm_link}' ) ) {
			$template = '';

			Notices::add(
				'warn',
				__( 'The confirmation message needs a {confirm_link} tag, so the default wording has been restored.', 'texttango' )
			);
		}

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$sync  = isset( $_POST['contacts_sync_enabled'] );
		$group = isset( $_POST['contacts_group_id'] )
			? sanitize_text_field( wp_unslash( $_POST['contacts_group_id'] ) )
			: '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		// Group ids are UUIDs; anything else would only fail at the API.
		$group = 1 === preg_match( '/^[0-9a-f-]{36}$/i', trim( $group ) ) ? strtolower( trim( $group ) ) : '';

		if ( $sync && '' === $group ) {
			Notices::add(
				'warn',
				__( 'Contact sync stays off until a contact group is chosen.', 'texttango' )
			);
		}

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$quiet       = isset( $_POST['quiet_hours_enabled'] );
		$quiet_all   = isset( $_POST['quiet_hours_include_transactional'] );
		$quiet_start = isset( $_POST['quiet_hours_start'] ) ? sanitize_text_field( wp_unslash( $_POST['quiet_hours_start'] ) ) : '';
		$quiet_end   = isset( $_POST['quiet_hours_end'] ) ? sanitize_text_field( wp_unslash( $_POST['quiet_hours_end'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$quiet_start = QuietHours::to_time( QuietHours::to_minutes( $quiet_start, 21 * 60 ) );
		$quiet_end   = QuietHours::to_time( QuietHours::to_minutes( $quiet_end, 8 * 60 ) );

		// A window that starts and ends at the same minute covers nothing, and
		// silently doing nothing is worse than saying so.
		if ( $quiet && $quiet_start === $quiet_end ) {
			Notices::add( 'warn', __( 'Quiet hours start and end at the same time, so no messages will be held.', 'texttango' ) );
		}

		$this->persist(
			Tabs::CONSENT,
			array(
				'consent_checkout_enabled'          => $enabled,
				'consent_checkout_label'            => trim( $label ),
				'consent_double_optin'              => $confirm,
				'consent_confirm_template'          => $template,
				'contacts_sync_enabled'             => $sync,
				'contacts_group_id'                 => $group,
				'quiet_hours_enabled'               => $quiet,
				'quiet_hours_start'                 => $quiet_start,
				'quiet_hours_end'                   => $quiet_end,
				'quiet_hours_include_transactional' => $quiet_all,
			)
		);
	}

	/**
	 * Save the Advanced tab.
	 */
	private function save_advanced(): void {
		// An import writes across every tab by design, so it runs before the
		// Advanced fields and its result is what the redirect reports.
		$this->save_import();

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$keep      = isset( $_POST['keep_data_on_uninstall'] );
		$retention = isset( $_POST['log_retention_days'] )
			? (int) sanitize_text_field( wp_unslash( $_POST['log_retention_days'] ) )
			: 0;
		$audit     = isset( $_POST['audit_retention_days'] )
			? (int) sanitize_text_field( wp_unslash( $_POST['audit_retention_days'] ) )
			: 0;
		$caps      = array();

		foreach ( array( 'spend_cap_daily', 'spend_cap_monthly', 'spend_cap_recipient' ) as $field ) {
			$caps[ $field ] = isset( $_POST[ $field ] )
				? max( 0, min( 1000000, (int) sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) ) )
				: 0;
		}

		$warn   = isset( $_POST['balance_warn_below'] )
			? max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['balance_warn_below'] ) ) )
			: 0.0;
		$pause  = isset( $_POST['balance_pause_below'] )
			? max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['balance_pause_below'] ) ) )
			: 0.0;
		$email  = isset( $_POST['email_fallback_enabled'] );
		$health = isset( $_POST['health_alert_enabled'] );
		$to     = isset( $_POST['health_alert_email'] )
			? sanitize_email( wp_unslash( $_POST['health_alert_email'] ) )
			: '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		// Stored blank rather than rejected when it is not an address: blank falls
		// back to the site admin, which is a working answer, and refusing the save
		// over a typo in an optional field is not.
		if ( '' !== $to && ! is_email( $to ) ) {
			Notices::add(
				'warn',
				__( 'That is not a valid email address, so fault alerts will go to the site admin address instead.', 'texttango' )
			);

			$to = '';
		}

		/*
		 * Pausing above the warning threshold would mean marketing stopped before
		 * anyone was told the balance was getting low — the warning would arrive
		 * to explain something that had already happened.
		 */
		if ( $warn > 0.0 && $pause > 0.0 && $pause > $warn ) {
			Notices::add(
				'warn',
				__( 'Marketing was set to pause before the low-balance warning would appear, so the warning threshold has been raised to match.', 'texttango' )
			);

			$warn = $pause;
		}

		if ( $caps['spend_cap_daily'] > 0 && $caps['spend_cap_monthly'] > 0 && $caps['spend_cap_monthly'] < $caps['spend_cap_daily'] ) {
			Notices::add(
				'warn',
				__( 'The monthly cap is lower than the daily one, so the daily cap can never be reached. Check both are what you meant.', 'texttango' )
			);
		}

		$this->persist(
			Tabs::ADVANCED,
			array_merge(
				$caps,
				array(
					'keep_data_on_uninstall' => $keep,
					'log_retention_days'     => max( 0, min( 3650, $retention ) ),
					'audit_retention_days'   => max( 0, min( 3650, $audit ) ),
					'balance_warn_below'     => $warn,
					'balance_pause_below'    => $pause,
					'email_fallback_enabled' => $email,
					'health_alert_enabled'   => $health,
					'health_alert_email'     => $to,
				)
			)
		);
	}

	/**
	 * Validate and store the API base URL.
	 */
	private function save_base_url(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$raw = isset( $_POST['api_base_url'] ) ? sanitize_text_field( wp_unslash( $_POST['api_base_url'] ) ) : '';
		$url = esc_url_raw( trim( $raw ) );

		if ( '' === $url ) {
			return;
		}

		$valid = Client::validate_base_url( $url );

		if ( is_wp_error( $valid ) ) {
			Notices::add( 'error', $valid->get_error_message() );

			return;
		}

		Settings::update( array( 'api_base_url' => untrailingslashit( $url ) ) );

		$host = (string) wp_parse_url( $url, PHP_URL_HOST );

		if ( 'https' !== strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ) ) {
			Notices::add(
				'warn',
				sprintf(
					/* translators: %s: hostname. */
					__( 'The API base URL uses plain HTTP. That is allowed for the local host %s, but never use it in production.', 'texttango' ),
					$host
				)
			);
		}
	}

	/**
	 * Save the Sign-in tab: OTP login and two-factor (spec §5).
	 */
	private function save_signin(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$template = isset( $_POST['otp_template'] )
			? sanitize_textarea_field( wp_unslash( $_POST['otp_template'] ) )
			: '';
		$role     = isset( $_POST['otp_registration_role'] )
			? sanitize_key( wp_unslash( $_POST['otp_registration_role'] ) )
			: '';
		$roles    = isset( $_POST['otp_2fa_roles'] ) && is_array( $_POST['otp_2fa_roles'] )
			? array_map( 'sanitize_key', wp_unslash( $_POST['otp_2fa_roles'] ) )
			: array();

		$numbers = array();

		foreach ( array( 'otp_ttl', 'otp_max_attempts', 'otp_phone_limit', 'otp_ip_limit' ) as $field ) {
			$numbers[ $field ] = isset( $_POST[ $field ] )
				? (int) sanitize_text_field( wp_unslash( $_POST[ $field ] ) )
				: 0;
		}

		$flags = array(
			'otp_login_enabled'      => isset( $_POST['otp_login_enabled'] ),
			'otp_on_wp_login'        => isset( $_POST['otp_on_wp_login'] ),
			'otp_allow_registration' => isset( $_POST['otp_allow_registration'] ),
			'otp_2fa_enabled'        => isset( $_POST['otp_2fa_enabled'] ),
			'checkout_otp_enabled'   => isset( $_POST['checkout_otp_enabled'] ),
		);
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		// A template with no {otp_code} would send a message with no code in it.
		if ( '' !== trim( $template ) && ! str_contains( $template, '{otp_code}' ) ) {
			Notices::add(
				'warn',
				__( 'The OTP template must contain {otp_code}, so the default template was kept.', 'texttango' )
			);

			$template = (string) Settings::get( 'otp_template', '' );
		}

		// Never let an operator lock every administrator out behind a factor the
		// site may not be able to deliver.
		$roles = array_values( array_filter( $roles, static fn( string $name ): bool => (bool) get_role( $name ) ) );

		$this->persist(
			Tabs::SIGNIN,
			array_merge(
				$flags,
				array(
					'otp_template'          => $template,
					'otp_registration_role' => '' !== $role && UserRepository::is_safe_self_registration_role( $role ) ? $role : '',
					'otp_2fa_roles'         => $roles,
					'otp_ttl'               => max( 60, min( 900, $numbers['otp_ttl'] ) ),
					'otp_max_attempts'      => max( 3, min( 10, $numbers['otp_max_attempts'] ) ),
					'otp_phone_limit'       => max( 1, min( 20, $numbers['otp_phone_limit'] ) ),
					'otp_ip_limit'          => max( 1, min( 100, $numbers['otp_ip_limit'] ) ),
				)
			)
		);
	}

	/**
	 * Normalize an optional phone field, keeping the stored value on failure.
	 *
	 * @param string $raw     Submitted value.
	 * @param string $warning Notice to show when it cannot be normalized.
	 */
	private function normalized_or_warn( string $raw, string $warning ): string {
		if ( '' === trim( $raw ) ) {
			return '';
		}

		$normalized = PhoneNumber::normalize( $raw );

		if ( is_wp_error( $normalized ) ) {
			Notices::add( 'warn', $warning );

			return (string) Settings::get( 'sandbox_test_number', '' );
		}

		return $normalized;
	}

	/**
	 * Store one encrypted secret.
	 *
	 * An empty field means "leave what is already there alone" — the field is
	 * rendered blank because the stored value is never sent to the browser, so
	 * treating blank as "clear it" would wipe credentials on every save. Removal
	 * is therefore an explicit checkbox.
	 *
	 * @param string $option        Option name.
	 * @param string $field         POST field holding the new value.
	 * @param string $remove_field  POST field naming the removal checkbox.
	 * @param string $saved_msg     Notice on save.
	 * @param string $removed_msg   Notice on removal.
	 * @param string $failed_msg    Notice when encryption is unavailable.
	 */
	private function save_secret(
		string $option,
		string $field,
		string $remove_field,
		string $saved_msg,
		string $removed_msg,
		string $failed_msg
	): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in handle_save().
		$remove = isset( $_POST[ $remove_field ] );
		$value  = isset( $_POST[ $field ] ) ? trim( sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( $remove ) {
			self::write( $option, '' );
			Notices::add( 'ok', $removed_msg );

			return;
		}

		if ( '' === $value ) {
			return;
		}

		if ( ! self::write( $option, $value ) ) {
			Notices::add( 'error', $failed_msg );

			return;
		}

		Notices::add( 'ok', $saved_msg );
	}

	/**
	 * Route a secret to the right setter.
	 *
	 * @param string $option Option name.
	 * @param string $value  Plaintext value.
	 */
	private static function write( string $option, string $value ): bool {
		if ( Settings::OPTION_API_KEY === $option ) {
			return Settings::set_api_key( $value );
		}

		return Settings::set_webhook_secret( $value );
	}

	/**
	 * Human labels and badge styles for each message status.
	 *
	 * @return array<string, array{label: string, badge: string}>
	 */
	public static function log_statuses(): array {
		return array(
			MessageLog::STATUS_QUEUED    => array(
				'label' => __( 'Queued', 'texttango' ),
				'badge' => 'tt-badge-off',
			),
			MessageLog::STATUS_SENDING   => array(
				'label' => __( 'Sending', 'texttango' ),
				'badge' => 'tt-badge-off',
			),
			MessageLog::STATUS_SENT      => array(
				'label' => __( 'Sent', 'texttango' ),
				'badge' => 'tt-badge-warn',
			),
			MessageLog::STATUS_DELIVERED => array(
				'label' => __( 'Delivered', 'texttango' ),
				'badge' => 'tt-badge-ok',
			),
			MessageLog::STATUS_FAILED    => array(
				'label' => __( 'Failed', 'texttango' ),
				'badge' => 'tt-badge-error',
			),
			MessageLog::STATUS_TIMEOUT   => array(
				'label' => __( 'Timed out', 'texttango' ),
				'badge' => 'tt-badge-error',
			),
			MessageLog::STATUS_SKIPPED   => array(
				'label' => __( 'Skipped', 'texttango' ),
				'badge' => 'tt-badge-off',
			),
			MessageLog::STATUS_SANDBOX   => array(
				'label' => __( 'Sandbox', 'texttango' ),
				'badge' => 'tt-badge-off',
			),
		);
	}

	/**
	 * Editable roles, for the registration and two-factor selectors.
	 *
	 * @return array<string, string>
	 */
	public static function roles(): array {
		$roles = array();

		foreach ( wp_roles()->roles as $slug => $details ) {
			$roles[ (string) $slug ] = translate_user_role( (string) $details['name'] );
		}

		return $roles;
	}

	/**
	 * Countries offered for phone normalization, Ghana first (spec positioning).
	 *
	 * @return array<string, string>
	 */
	public static function countries(): array {
		return array(
			'GH' => __( 'Ghana (+233)', 'texttango' ),
			'NG' => __( 'Nigeria (+234)', 'texttango' ),
			'KE' => __( 'Kenya (+254)', 'texttango' ),
			'ZA' => __( 'South Africa (+27)', 'texttango' ),
			'CI' => __( 'Côte d’Ivoire (+225)', 'texttango' ),
			'TG' => __( 'Togo (+228)', 'texttango' ),
			'BJ' => __( 'Benin (+229)', 'texttango' ),
			'BF' => __( 'Burkina Faso (+226)', 'texttango' ),
			'GB' => __( 'United Kingdom (+44)', 'texttango' ),
			'US' => __( 'United States (+1)', 'texttango' ),
		);
	}
}
