<?php
/**
 * Settings screen tabs.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Admin;

use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * The one place that knows what tabs exist and what each of them owns.
 *
 * The ownership map is the important half. Every checkbox on the screen is read
 * as `isset( $_POST['x'] )`, so an absent field means "off" rather than
 * "unchanged" — which means a tab that saves a key it does not render will
 * silently clear it. Routing every save through {@see self::owns()} keeps each
 * tab confined to its own fields, and `TabsTest` asserts the map covers every
 * setting exactly once so a newly added setting cannot quietly go unowned.
 */
final class Tabs {

	public const CONNECTION    = 'connection';
	public const NOTIFICATIONS = 'notifications';
	public const CONSENT       = 'consent';
	public const MARKETING     = 'marketing';
	public const TESTING       = 'testing';
	public const SIGNIN        = 'signin';
	public const FORMS         = 'forms';
	public const LOGS          = 'logs';
	public const ADVANCED      = 'advanced';

	/**
	 * Tab shown when none is requested.
	 */
	public const DEFAULT_TAB = self::CONNECTION;

	/**
	 * Tab slugs mapped to their labels, in display order.
	 *
	 * @return array<string, string>
	 */
	public static function all(): array {
		return array(
			self::CONNECTION    => __( 'Connection', 'texttango' ),
			self::NOTIFICATIONS => __( 'Notifications', 'texttango' ),
			self::CONSENT       => __( 'Consent', 'texttango' ),
			self::MARKETING     => __( 'Marketing', 'texttango' ),
			self::TESTING       => __( 'Testing', 'texttango' ),
			self::SIGNIN        => __( 'Sign-in', 'texttango' ),
			self::FORMS         => __( 'Forms', 'texttango' ),
			self::LOGS          => __( 'Logs', 'texttango' ),
			self::ADVANCED      => __( 'Advanced', 'texttango' ),
		);
	}

	/**
	 * Tab slugs mapped to their icons.
	 *
	 * Deliberately beside {@see self::all()} rather than in the template: two
	 * lists keyed by the same slugs drift the moment they live apart, and a tab
	 * added without an icon is caught by a test rather than by somebody noticing
	 * a gap in the strip.
	 *
	 * @return array<string, string>
	 */
	public static function icons(): array {
		return array(
			self::CONNECTION    => 'plug',
			self::NOTIFICATIONS => 'bell',
			self::CONSENT       => 'shield-check',
			self::MARKETING     => 'megaphone',
			self::TESTING       => 'flask-conical',
			self::SIGNIN        => 'log-in',
			self::FORMS         => 'clipboard-list',
			self::LOGS          => 'scroll-text',
			self::ADVANCED      => 'sliders-horizontal',
		);
	}

	/**
	 * The icon for one tab, or an empty string.
	 *
	 * @param string $tab Tab slug.
	 */
	public static function icon( string $tab ): string {
		return self::icons()[ $tab ] ?? '';
	}

	/**
	 * The tabs, resolved for the shared tab bar.
	 *
	 * The same shape {@see CampaignsPage::view_rows()} returns, because both
	 * screens render through one partial — the strip is built once so the two
	 * cannot drift into two different-looking navigations.
	 *
	 * @param string $current Slug of the tab being rendered.
	 * @return array<int, array{label: string, icon: string, url: string, active: bool}>
	 */
	public static function rows( string $current ): array {
		$rows = array();

		foreach ( self::all() as $slug => $label ) {
			$rows[] = array(
				'label'  => $label,
				'icon'   => self::icon( $slug ),
				'url'    => self::url( $slug ),
				'active' => $slug === $current,
			);
		}

		return $rows;
	}

	/**
	 * Settings keys each tab is allowed to write.
	 *
	 * A saver must never touch a key outside its own list.
	 *
	 * @return array<string, string[]>
	 */
	public static function owned_keys(): array {
		return array(
			self::CONNECTION    => array(
				'api_base_url',
				'sender_id',
				'default_country',
			),
			self::NOTIFICATIONS => array(
				'notifications',
				'admin_recipients',
				'order_notes_enabled',
			),
			self::CONSENT       => array(
				'consent_checkout_enabled',
				'consent_checkout_label',
				'consent_double_optin',
				'consent_confirm_template',
				'contacts_sync_enabled',
				'contacts_group_id',
				'quiet_hours_enabled',
				'quiet_hours_start',
				'quiet_hours_end',
				'quiet_hours_include_transactional',
			),
			self::MARKETING     => array(
				'abandoned_cart_enabled',
				'abandoned_cart_delay',
				'abandoned_cart_template',
				'abandoned_cart_followup_hours',
				'abandoned_cart_followup_template',
			),
			self::TESTING       => array(
				'sandbox_mode',
				'sandbox_behavior',
				'sandbox_test_number',
				// Written by the test-SMS endpoint rather than the form, but it
				// belongs to this tab and must be accounted for.
				'test_phone',
			),
			self::SIGNIN        => array(
				'otp_login_enabled',
				'otp_on_wp_login',
				'otp_allow_registration',
				'otp_registration_role',
				'otp_template',
				'otp_ttl',
				'otp_max_attempts',
				'otp_phone_limit',
				'otp_ip_limit',
				'otp_2fa_enabled',
				'otp_2fa_roles',
				'checkout_otp_enabled',
			),
			/*
			 * Per-form configuration is its own option rather than a settings key,
			 * because it is keyed by form and grows with the site's forms rather
			 * than being a fixed list. So this tab owns no settings, and the empty
			 * list is what stops a POST naming it from writing any.
			 */
			self::FORMS         => array(),
			// A read-only screen. It owns no settings, and the empty list is what
			// stops a POST naming this tab from writing anything at all.
			self::LOGS          => array(),
			self::ADVANCED      => array(
				'keep_data_on_uninstall',
				'log_retention_days',
				'audit_retention_days',
				'spend_cap_daily',
				'spend_cap_monthly',
				'spend_cap_recipient',
				'balance_warn_below',
				'balance_pause_below',
				'email_fallback_enabled',
				'health_alert_enabled',
				'health_alert_email',
			),
		);
	}

	/**
	 * Whether a tab is allowed to write a settings key.
	 *
	 * @param string $tab Tab slug.
	 * @param string $key Settings key.
	 */
	public static function owns( string $tab, string $key ): bool {
		return in_array( $key, self::owned_keys()[ $tab ] ?? array(), true );
	}

	/**
	 * Keep only the keys a tab is allowed to write.
	 *
	 * The last line of defence before {@see Settings::update()}: even if a saver
	 * assembles a stray key, it cannot reach storage from the wrong tab.
	 *
	 * @param string               $tab    Tab slug.
	 * @param array<string, mixed> $values Candidate settings.
	 * @return array<string, mixed>
	 */
	public static function restrict( string $tab, array $values ): array {
		$allowed = array_flip( self::owned_keys()[ $tab ] ?? array() );

		return array_intersect_key( $values, $allowed );
	}

	/**
	 * Whether a slug names a real tab.
	 *
	 * @param string $tab Candidate slug.
	 */
	public static function exists( string $tab ): bool {
		return array_key_exists( $tab, self::all() );
	}

	/**
	 * Resolve an untrusted slug to a real tab.
	 *
	 * Anything unknown falls back to the default, so a hand-edited query string
	 * can never select a panel that does not exist.
	 *
	 * @param mixed $candidate Raw slug from a request.
	 */
	public static function from_request( $candidate ): string {
		$slug = is_string( $candidate ) ? sanitize_key( $candidate ) : '';

		return self::exists( $slug ) ? $slug : self::DEFAULT_TAB;
	}

	/**
	 * The tab the current request is asking for.
	 */
	public static function current(): string {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Chooses which read-only panel to render; validated against the registry.
		return self::from_request( isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '' );
	}

	/**
	 * Admin URL for a tab.
	 *
	 * @param string                $tab  Tab slug.
	 * @param array<string, string> $args Extra query arguments — the Logs
	 *                                    filter, for one, which is URL-driven so
	 *                                    a filtered view can be linked and the
	 *                                    back button behaves.
	 */
	public static function url( string $tab, array $args = array() ): string {
		return add_query_arg(
			array_merge(
				array(
					'page' => Menu::SETTINGS_SLUG,
					'tab'  => self::from_request( $tab ),
				),
				$args
			),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * Nonce action for a tab's form.
	 *
	 * Per-tab, so a form rendered on one tab cannot be replayed against another.
	 *
	 * @param string $tab Tab slug.
	 */
	public static function nonce_action( string $tab ): string {
		return SettingsPage::SAVE_NONCE . '_' . self::from_request( $tab );
	}

	/**
	 * Path to a tab's panel template.
	 *
	 * @param string $tab Tab slug.
	 */
	public static function template( string $tab ): string {
		return TEXTTANGO_PATH . 'templates/admin/tabs/' . self::from_request( $tab ) . '.php';
	}
}
