<?php
/**
 * The guided setup wizard.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Admin;

use TextTango\Install\Activator;
use TextTango\Sending\BalanceWatch;
use TextTango\Support\Sandbox;
use TextTango\Support\Settings;
use TextTango\Webhook\Registration;
use TextTango\Webhook\Route;
use TextTango\Webhook\WebhookState;
use TextTango\WooCommerce\Events;
use TextTango\WooCommerce\Notifications;

defined( 'ABSPATH' ) || exit;

/**
 * Walks a merchant from an empty install to a working one (spec §20).
 *
 * Setting this plugin up means five things spread across four tabs, and until
 * now the only way to know which you had done was to remember. The tabs are the
 * right home for changing a setting later; they are a poor way to find out what
 * is still missing, and a worse way to do it the first time.
 *
 * Presented as a **list of stages** rather than a linear flow, in the shape of a
 * deployment pipeline: one row each, status at a glance on the left, a short
 * result on the right, and the controls for that stage behind a disclosure. This
 * page lives permanently in the menu and is read far more often than it is
 * walked through, so it has to work as a health view as much as a setup flow —
 * a merchant glancing to see everything is green, expanding only what needs
 * attention.
 *
 * ### Nothing is stored about where you are
 *
 * There is no saved cursor. Each stage asks the site whether it is done — is a
 * token stored and readable, is a sender ID set, has a signed request ever
 * arrived — so the page opens at the first thing still needing attention,
 * survives a half-finished attempt, and cannot drift out of step with reality.
 * A stored cursor would eventually claim setup was complete on a site where
 * somebody had since removed the token.
 *
 * ### A stage this site cannot do is shown, not hidden
 *
 * Registering the receipt URL cannot succeed on a local or staging site, because
 * TextTango will not accept a callback it cannot reach. That stage keeps its row
 * and its explanation — hiding it would withhold the one thing a developer most
 * needs to know, which is that it works once the site is live — but it is left
 * out of the count, so it can never hold the page at four of five for ever.
 */
final class Wizard {

	/**
	 * Page slug.
	 */
	public const SLUG = 'texttango-setup';

	/**
	 * `admin_post` action for every step that saves something.
	 */
	public const ACTION = 'texttango_wizard_step';

	/**
	 * The stage order, top to bottom.
	 *
	 * @var string[]
	 */
	public const ORDER = array(
		'token',
		'sender',
		'receipts',
		'test',
		'events',
	);

	/**
	 * Events the last step offers, pre-ticked.
	 *
	 * Two, not seven. A wizard that switches on every notification for you is a
	 * wizard that texts each customer several times per order without anybody
	 * having chosen it. These are the two nearly every store wants: payment
	 * received, and order on its way.
	 *
	 * @var string[]
	 */
	public const RECOMMENDED = array(
		'order_processing',
		'order_completed',
	);

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'admin_post_' . self::ACTION, array( $this, 'handle_step' ) );
		add_action( 'admin_notices', array( $this, 'maybe_prompt' ) );
	}

	/**
	 * Where the page lives.
	 *
	 * @param string $open Optional stage to expand on arrival.
	 */
	public static function url( string $open = '' ): string {
		$url = admin_url( 'admin.php?page=' . self::SLUG );

		return '' !== $open ? add_query_arg( 'open', $open, $url ) : $url;
	}

	/**
	 * Short names for each stage row.
	 *
	 * @return array<string, string>
	 */
	public static function labels(): array {
		return array(
			'token'    => __( 'Connect', 'texttango' ),
			'sender'   => __( 'Sender ID', 'texttango' ),
			'receipts' => __( 'Receipts', 'texttango' ),
			'test'     => __( 'Test', 'texttango' ),
			'events'   => __( 'Notifications', 'texttango' ),
		);
	}

	/**
	 * Each step, and whether the site says it is done.
	 *
	 * @return array<int, array{key: string, title: string, done: bool, blocked: bool, summary: string}>
	 */
	public static function steps(): array {
		return self::build( self::facts() );
	}

	/**
	 * What is currently true about this site.
	 *
	 * @return array<string, mixed>
	 */
	public static function facts(): array {
		return array(
			'connected'         => Settings::secret_exists( Settings::OPTION_API_KEY )
				&& ! Settings::secret_is_unreadable( Settings::OPTION_API_KEY ),
			'balance'           => self::balance(),
			'sender'            => trim( (string) Settings::get( 'sender_id', '' ) ),
			'receipts_possible' => Registration::is_reachable_from_outside( Route::url() ),
			'receipts_verified' => WebhookState::is_verified(),
			'tested'            => '' !== trim( (string) Settings::get( 'test_phone', '' ) ),
			'woo_active'        => Notifications::woocommerce_active(),
			'events_enabled'    => count(
				array_filter( Events::settings(), static fn( $row ): bool => ! empty( $row['enabled'] ) )
			),
		);
	}

	/**
	 * The balance, formatted with its currency.
	 *
	 * Read from the hourly watcher first, which is both fresher and already
	 * formatted. The connection state is the fallback, and it needs composing:
	 * it stores the amount as a bare float with the currency under a separate
	 * key, which is how this screen came to say "Balance 91.83" with no idea
	 * what 91.83 was denominated in.
	 */
	public static function balance(): string {
		/*
		 * One source, no fallback. This used to prefer the watched reading and
		 * fall back to whatever the last connection test saw, which was a
		 * workaround for two stores of the same figure. The connection test now
		 * writes its reading through to BalanceWatch, so there is nothing left to
		 * fall back to — and nothing left to disagree.
		 */
		return BalanceWatch::formatted();
	}

	/**
	 * Turn what is true about a site into a checklist.
	 *
	 * Pure, with every lookup passed in. The interesting part of this class is
	 * which steps a given site can even attempt — a local install can never
	 * register a callback, a site without WooCommerce has no order notifications
	 * — and that is worth being able to reason about, and test, without a
	 * database, a network, or WooCommerce loaded.
	 *
	 * @param array<string, mixed> $facts What is true about the site.
	 * @return array<int, array{key: string, title: string, done: bool, blocked: bool, summary: string}>
	 */
	public static function build( array $facts ): array {
		$facts = array_merge(
			array(
				'connected'         => false,
				'balance'           => '',
				'sender'            => '',
				'receipts_possible' => false,
				'receipts_verified' => false,
				'tested'            => false,
				'woo_active'        => false,
				'events_enabled'    => 0,
			),
			$facts
		);

		$connected = (bool) $facts['connected'];
		$sender    = (string) $facts['sender'];
		$enabled   = (int) $facts['events_enabled'];
		$has_woo   = (bool) $facts['woo_active'];
		$verified  = (bool) $facts['receipts_verified'];
		$local     = ! (bool) $facts['receipts_possible'];

		return array(
			array(
				'key'     => 'token',
				'title'   => __( 'Connect your account', 'texttango' ),
				'done'    => $connected,
				'blocked' => false,
				'summary' => self::token_summary( $connected, (string) $facts['balance'] ),
			),
			array(
				'key'     => 'sender',
				'title'   => __( 'Choose who messages come from', 'texttango' ),
				'done'    => '' !== $sender,
				'blocked' => ! $connected,
				'summary' => '' !== $sender
					? sprintf(
						/* translators: %s: the sender ID. */
						__( 'Messages are sent as "%s".', 'texttango' ),
						$sender
					)
					: __( 'Pick one of your approved sender IDs, so customers see your name rather than a number.', 'texttango' ),
			),
			array(
				'key'     => 'receipts',
				'title'   => __( 'Let delivery receipts reach this site', 'texttango' ),
				'done'    => $verified,
				'blocked' => $local,
				'summary' => self::receipts_summary( $verified, $local ),
			),
			array(
				'key'     => 'test',
				'title'   => __( 'Send yourself a test message', 'texttango' ),
				'done'    => (bool) $facts['tested'],
				'blocked' => ! $connected,
				'summary' => $facts['tested']
					? __( 'A test has been sent from this site.', 'texttango' )
					: __( 'The only way to prove the whole path works. It sends a real SMS and uses credit.', 'texttango' ),
			),
			array(
				'key'     => 'events',
				'title'   => __( 'Switch on order notifications', 'texttango' ),
				'done'    => $enabled > 0,
				'blocked' => ! $has_woo,
				'summary' => self::events_summary( $enabled, $has_woo ),
			),
		);
	}

	/**
	 * One step's state as a single word.
	 *
	 * Pure, and the one place the three-way distinction lives, so the breadcrumb
	 * and the step body cannot disagree about what a step's state is.
	 *
	 * @param array<string, mixed> $step One entry from {@see self::build()}.
	 * @return string One of 'done', 'blocked', 'todo'.
	 */
	public static function status( array $step ): string {
		if ( ! empty( $step['done'] ) ) {
			return 'done';
		}

		return empty( $step['blocked'] ) ? 'todo' : 'blocked';
	}

	/**
	 * The step being shown.
	 *
	 * An explicit `?open=` wins, so a link can point at one. Otherwise the first
	 * stage still needing attention, which is what makes arriving here show the
	 * thing worth doing rather than always the first row.
	 *
	 * A stage this site cannot attempt is passed over, and only when choosing the
	 * default. Its row stays, expandable, carrying the reason — that reason is
	 * worth reading once — but opening it every visit would be opening a stage
	 * that can never be completed, and it is already left out of the count.
	 *
	 * @param string                           $requested Raw `?open=` value.
	 * @param array<int, array<string, mixed>> $steps     Stage list.
	 */
	public static function open_step( string $requested, array $steps ): string {
		$requested = sanitize_key( $requested );

		if ( in_array( $requested, self::ORDER, true ) ) {
			return $requested;
		}

		foreach ( $steps as $step ) {
			if ( 'todo' === self::status( $step ) ) {
				return (string) $step['key'];
			}
		}

		// Everything possible is done, so nothing is forced open.
		return '';
	}

	/**
	 * The stage after a given one, or '' when there is none.
	 *
	 * Used after saving, so the next thing worth doing is already open. An empty
	 * string means the page decides for itself, which is the right answer both
	 * for the last stage and for anything unrecognised.
	 *
	 * @param string $step Stage just saved.
	 */
	public static function next_step( string $step ): string {
		$index = array_search( $step, self::ORDER, true );

		if ( false === $index || $index >= count( self::ORDER ) - 1 ) {
			return '';
		}

		return self::ORDER[ $index + 1 ];
	}

	/**
	 * How many steps are done, ignoring ones this site cannot do.
	 *
	 * @return array{done: int, total: int}
	 */
	public static function progress(): array {
		return self::count_progress( self::steps() );
	}

	/**
	 * Count a checklist.
	 *
	 * Pure. A step this site cannot attempt is left out of the total entirely —
	 * a developer on a local domain who has done everything possible must not be
	 * left staring at four of five for ever.
	 *
	 * @param array<int, array<string, mixed>> $steps Checklist.
	 * @return array{done: int, total: int}
	 */
	public static function count_progress( array $steps ): array {
		$possible = array_filter( $steps, static fn( array $step ): bool => 'blocked' !== self::status( $step ) );

		return array(
			'done'  => count( array_filter( $possible, static fn( array $step ): bool => 'done' === self::status( $step ) ) ),
			'total' => count( $possible ),
		);
	}

	/**
	 * Whether there is anything left worth prompting about.
	 */
	public static function is_complete(): bool {
		$progress = self::progress();

		return $progress['done'] >= $progress['total'];
	}

	/**
	 * The few facts worth putting in the header line.
	 *
	 * Deliberately not one per stage. Each stage already reports itself in its
	 * own row, and repeating that above only invites the two to disagree — the
	 * header would have said "Receipts unproven" beside a row correctly reading
	 * "Not possible here". What is left is the state a merchant wants at a
	 * glance and cannot read off any single row.
	 *
	 * @return array<string, string>
	 */
	public static function summary(): array {
		$enabled = count(
			array_filter( Events::settings(), static fn( $row ): bool => ! empty( $row['enabled'] ) )
		);

		return array(
			'balance' => '' !== self::balance() ? self::balance() : __( 'Balance not checked', 'texttango' ),
			'events'  => (string) sprintf(
				/* translators: %d: number of notifications switched on. */
				_n( '%d notification on', '%d notifications on', $enabled, 'texttango' ),
				$enabled
			),
			'sandbox' => Sandbox::is_active()
				? __( 'Sandbox on', 'texttango' )
				: __( 'Sandbox off', 'texttango' ),
		);
	}

	/**
	 * A short result for a stage, shown right-aligned on its row.
	 *
	 * Pure, and built from the same facts the stage list is, so the column you
	 * scan cannot disagree with the state of the stage beside it. Every stage has
	 * one in every state: a blank cell reads as "unknown", which is never what is
	 * meant here.
	 *
	 * @param string               $key   Stage key.
	 * @param array<string, mixed> $facts What is true about the site.
	 */
	public static function meta( string $key, array $facts ): string {
		switch ( $key ) {
			case 'token':
				return ! empty( $facts['connected'] )
					? ( '' !== (string) ( $facts['balance'] ?? '' )
						? (string) $facts['balance']
						: __( 'Connected', 'texttango' ) )
					: __( 'Not connected', 'texttango' );

			case 'sender':
				return '' !== (string) ( $facts['sender'] ?? '' )
					? (string) $facts['sender']
					: __( 'Not set', 'texttango' );

			case 'receipts':
				if ( ! empty( $facts['receipts_verified'] ) ) {
					return __( 'Arriving', 'texttango' );
				}

				return empty( $facts['receipts_possible'] )
					? __( 'Not possible here', 'texttango' )
					: __( 'Unproven', 'texttango' );

			case 'test':
				return ! empty( $facts['tested'] )
					? __( 'Sent', 'texttango' )
					: __( 'Never', 'texttango' );

			case 'events':
				if ( empty( $facts['woo_active'] ) ) {
					return __( 'No WooCommerce', 'texttango' );
				}

				$enabled = (int) ( $facts['events_enabled'] ?? 0 );

				return $enabled > 0
					? sprintf(
						/* translators: %d: number of notifications switched on. */
						_n( '%d on', '%d on', $enabled, 'texttango' ),
						$enabled
					)
					: __( 'None on', 'texttango' );

			default:
				return '';
		}
	}

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

		$facts = self::facts();
		$steps = self::build( $facts );

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Chooses which row starts expanded; changes nothing.
		$requested = isset( $_GET['open'] ) ? sanitize_key( wp_unslash( $_GET['open'] ) ) : '';

		load_template(
			TEXTTANGO_PATH . 'templates/admin/wizard.php',
			false,
			array(
				'steps'      => $steps,
				'facts'      => $facts,
				'labels'     => self::labels(),
				'open'       => self::open_step( $requested, $steps ),
				'progress'   => self::count_progress( $steps ),
				'complete'   => self::count_progress( $steps )['done'] >= self::count_progress( $steps )['total'],
				'summary'    => self::summary(),
				'notices'    => Notices::take(),
				'action'     => self::ACTION,
				'events'     => Events::all(),
				'event_on'   => Events::settings(),
				'recommend'  => self::RECOMMENDED,
				'webhook'    => Route::url(),
				'test_phone' => (string) Settings::get( 'test_phone', '' ),
				'sender'     => (string) Settings::get( 'sender_id', '' ),
				'token_page' => SettingsPage::TOKEN_PAGE,
				'abilities'  => SettingsPage::REQUIRED_ABILITIES,
				'settings'   => Tabs::url( Tabs::CONNECTION ),
				'logs_url'   => Tabs::url( Tabs::LOGS ),
			)
		);
	}

	/**
	 * Save one step, then move to the next.
	 *
	 * Each branch writes exactly one setting. Reusing the Connection tab's saver
	 * would have been shorter and wrong: it reads every field as
	 * `isset( $_POST[...] )`, so a form carrying only a token would save the
	 * sender ID as empty and wipe it.
	 */
	public function handle_step(): 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::ACTION );

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified on the line above.
		$step = isset( $_POST['step'] ) ? sanitize_key( wp_unslash( $_POST['step'] ) ) : '';

		switch ( $step ) {
			case 'token':
				$this->save_token();
				break;
			case 'sender':
				$this->save_sender();
				break;
			case 'events':
				$this->save_events();
				break;
			default:
				break;
		}

		wp_safe_redirect( self::url( self::next_step( $step ) ) );
		exit;
	}

	/**
	 * Store the API token, and nothing else.
	 */
	private function save_token(): void {
		// Sanitized the same way the Connection tab does it, so a token pasted
		// here and one pasted there cannot be treated differently.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in handle_step().
		$token = isset( $_POST['api_key'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) ) : '';

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

		if ( ! Settings::set_api_key( $token ) ) {
			Notices::add(
				'error',
				__( 'The token could not be encrypted, so it was not saved. Check that the sodium extension is available.', 'texttango' )
			);

			return;
		}

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

	/**
	 * Store the sender ID, and nothing else.
	 */
	private function save_sender(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in handle_step().
		$sender = isset( $_POST['sender_id'] ) ? sanitize_text_field( wp_unslash( $_POST['sender_id'] ) ) : '';

		// Alphanumeric, at most 11 characters — the same rule the Connection tab
		// applies, because it is the platform's rule rather than the screen's.
		$sender = substr( (string) preg_replace( '/[^A-Za-z0-9 ]/', '', $sender ), 0, 11 );

		Settings::update( array( 'sender_id' => trim( $sender ) ) );
	}

	/**
	 * Switch on the notifications somebody ticked.
	 */
	private function save_events(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in handle_step().
		$requested = isset( $_POST['events'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['events'] ) ) : array();

		$chosen = self::allowed( $requested );

		Settings::update( array( 'notifications' => self::enable( $chosen, Events::settings() ) ) );

		if ( ! empty( $chosen ) ) {
			Notices::add(
				'ok',
				sprintf(
					/* translators: %d: number of notifications switched on. */
					_n( '%d notification switched on.', '%d notifications switched on.', count( $chosen ), 'texttango' ),
					count( $chosen )
				)
			);
		}
	}

	/**
	 * Keep only the events that exist.
	 *
	 * A form field is untrusted input, and an invented name written into the
	 * notifications map would sit there for ever as a setting nothing reads and
	 * nobody can find in the interface to remove.
	 *
	 * @param array<int, string> $requested Event names from the form.
	 * @return array<int, string>
	 */
	public static function allowed( array $requested ): array {
		return array_values( array_intersect( $requested, array_keys( Events::all() ) ) );
	}

	/**
	 * Switch on the chosen events, leaving everything else exactly as it was.
	 *
	 * Pure, and additive on purpose: this screen offers two events, and a
	 * merchant who has already configured five others must not lose them by
	 * finishing setup. Nothing here can switch anything **off**.
	 *
	 * @param array<int, string>   $chosen  Events to enable.
	 * @param array<string, mixed> $current Notification settings as they stand.
	 * @return array<string, mixed>
	 */
	public static function enable( array $chosen, array $current ): array {
		foreach ( $chosen as $event ) {
			$row = isset( $current[ $event ] ) && is_array( $current[ $event ] ) ? $current[ $event ] : array();

			$row['enabled']    = true;
			$current[ $event ] = $row;
		}

		return $current;
	}

	/**
	 * What to say about the connection step.
	 *
	 * @param bool   $connected Whether a readable token is stored.
	 * @param string $balance   Formatted balance, with its currency.
	 */
	private static function token_summary( bool $connected, string $balance ): string {
		if ( ! $connected ) {
			return __( 'Paste an API token from the TextTango dashboard. Nothing can send without one.', 'texttango' );
		}

		if ( '' === $balance ) {
			return __( 'A token is stored. Test the connection to confirm it works.', 'texttango' );
		}

		return sprintf(
			/* translators: %s: formatted balance, e.g. "GHS 91.83". */
			__( 'Connected. Balance %s.', 'texttango' ),
			$balance
		);
	}

	/**
	 * What to say about the receipts step.
	 *
	 * @param bool $verified Whether anything signed has arrived.
	 * @param bool $local    Whether this site is unreachable from outside.
	 */
	private static function receipts_summary( bool $verified, bool $local ): string {
		if ( $verified ) {
			return __( 'A signed request from TextTango has arrived here, so delivery statuses will reach the log.', 'texttango' );
		}

		if ( $local ) {
			return __( 'Not possible on a local or staging site — TextTango only posts to a public HTTPS address. Everything else works; come back to this once the site is live.', 'texttango' );
		}

		return __( 'Register this site so the message log can move past "sent", then prove it with a test event.', 'texttango' );
	}

	/**
	 * What to say about the notifications step.
	 *
	 * @param int  $enabled How many events are on.
	 * @param bool $has_woo Whether WooCommerce is active.
	 */
	private static function events_summary( int $enabled, bool $has_woo ): string {
		if ( ! $has_woo ) {
			return __( 'WooCommerce is not active, so there are no order notifications to switch on. Sign-in codes and the public API work regardless.', 'texttango' );
		}

		if ( $enabled > 0 ) {
			return sprintf(
				/* translators: %d: number of events switched on. */
				_n( '%d notification is switched on.', '%d notifications are switched on.', $enabled, 'texttango' ),
				$enabled
			);
		}

		return __( 'Nothing is switched on yet, so no customer has been texted. Choose which moments are worth a message.', 'texttango' );
	}

	/**
	 * Point a new install at the wizard.
	 *
	 * Shown only where somebody can act on it, and only while there is something
	 * left to do — a prompt that outlives its usefulness is one people learn to
	 * dismiss without reading.
	 *
	 * Also stands down once {@see FirstRun}'s own setup notice has been
	 * dismissed. The two notices cover the same "setup isn't finished" fact;
	 * without this check, dismissing one leaves the other reappearing on every
	 * other admin screen with no way to close it for good.
	 */
	public function maybe_prompt(): void {
		if ( ! current_user_can( Activator::CAPABILITY ) || self::is_complete() || FirstRun::is_dismissed() ) {
			return;
		}

		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;

		// Not on our own screens, where the person is already doing the thing it
		// would be nagging them about.
		if ( $screen && false !== strpos( (string) $screen->id, 'texttango' ) ) {
			return;
		}

		$progress = self::progress();

		printf(
			'<div class="notice notice-info is-dismissible"><p><strong>%1$s</strong> %2$s <a href="%3$s">%4$s</a></p></div>',
			esc_html__( 'TextTango is not finished setting up.', 'texttango' ),
			esc_html(
				sprintf(
					/* translators: 1: steps done, 2: steps in total. */
					__( '%1$d of %2$d steps done.', 'texttango' ),
					$progress['done'],
					$progress['total']
				)
			),
			esc_url( self::url() ),
			esc_html__( 'Finish setup', 'texttango' )
		);
	}
}
