<?php
/**
 * Admin AJAX endpoints.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Admin;

use TextTango\Api\Client;
use TextTango\Api\PhoneNumber;
use TextTango\Audit\Recorder;
use TextTango\Install\Activator;
use TextTango\Install\Importer;
use TextTango\Messages\Resend;
use TextTango\Sending\BalanceWatch;
use TextTango\Sending\Sender;
use TextTango\Support\Settings;
use TextTango\Webhook\Reconciler;
use TextTango\Webhook\Registration;
use TextTango\Webhook\WebhookState;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Endpoints behind the buttons on the settings screen.
 *
 * Every handler checks the nonce and the capability before doing anything, and
 * no handler ever returns a token or signing secret to the browser (spec §12, §13).
 */
final class Ajax {

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'wp_ajax_texttango_test_connection', array( $this, 'test_connection' ) );
		add_action( 'wp_ajax_texttango_sender_ids', array( $this, 'sender_ids' ) );
		add_action( 'wp_ajax_texttango_send_test_sms', array( $this, 'send_test_sms' ) );
		add_action( 'wp_ajax_texttango_resync_pending', array( $this, 'resync_pending' ) );
		add_action( 'wp_ajax_texttango_resend_message', array( $this, 'resend_message' ) );
		add_action( 'wp_ajax_texttango_import_consent', array( $this, 'import_consent' ) );
		add_action( 'wp_ajax_texttango_contact_groups', array( $this, 'contact_groups' ) );
		add_action( 'wp_ajax_texttango_webhook_status', array( $this, 'webhook_status' ) );
		add_action( 'wp_ajax_texttango_webhook_register', array( $this, 'webhook_register' ) );
		add_action( 'wp_ajax_texttango_webhook_test', array( $this, 'webhook_test' ) );
		add_action( 'wp_ajax_texttango_webhook_test_result', array( $this, 'webhook_test_result' ) );
		add_action( 'wp_ajax_texttango_webhook_rotate', array( $this, 'webhook_rotate' ) );
	}

	/**
	 * Report what the token's webhook configuration says (spec §20).
	 */
	public function webhook_status(): void {
		$this->authorize();

		$status = Registration::status();

		if ( is_wp_error( $status ) ) {
			$this->fail( $status );
		}

		wp_send_json_success( $status );
	}

	/**
	 * Point the token's SMS callback at this site.
	 */
	public function webhook_register(): void {
		$this->authorize();

		$status = Registration::register();

		if ( is_wp_error( $status ) ) {
			$this->fail( $status );
		}

		wp_send_json_success(
			array_merge(
				(array) $status,
				array( 'message' => __( 'Registered. Send a test event to confirm receipts reach this site.', 'texttango' ) )
			)
		);
	}

	/**
	 * Ask TextTango to send a test event here.
	 */
	public function webhook_test(): void {
		$this->authorize();

		$result = Registration::send_test();

		if ( is_wp_error( $result ) ) {
			$this->fail( $result );
		}

		wp_send_json_success( $result );
	}

	/**
	 * Whether the test event has landed yet.
	 *
	 * Separate from dispatching it because TextTango sends the event from a queued
	 * job, so the response to the request that asked for it says only that the
	 * request was accepted. Arrival is observed here, at the end that receives it.
	 */
	public function webhook_test_result(): void {
		$this->authorize();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- authorize() checks the nonce first.
		$since = isset( $_GET['since'] ) ? absint( wp_unslash( $_GET['since'] ) ) : 0;

		wp_send_json_success(
			array(
				'arrived' => Registration::arrived_since( $since ),
				'at'      => WebhookState::last_arrival(),
			)
		);
	}

	/**
	 * Replace the webhook signing secret.
	 */
	public function webhook_rotate(): void {
		$this->authorize();

		$result = Registration::rotate_secret();

		if ( is_wp_error( $result ) ) {
			$this->fail( $result );
		}

		wp_send_json_success(
			array(
				// Deliberately not the secret. It is stored encrypted and never
				// travels to the browser, the same rule the token follows.
				'message' => __( 'A new signing secret was issued and saved. Send a test event to confirm receipts still verify.', 'texttango' ),
			)
		);
	}

	/**
	 * List contact groups for the picker on the Consent tab (spec §7).
	 */
	public function contact_groups(): void {
		$this->authorize();

		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			$this->fail( $client );
		}

		$response = $client->contact_groups();

		if ( is_wp_error( $response ) ) {
			$this->fail( $response );
		}

		$groups = self::parse_contact_groups( $response );

		wp_send_json_success(
			array(
				'groups'  => $groups,
				'message' => empty( $groups )
					? __( 'No contact groups on this account yet. Create one in the TextTango dashboard.', 'texttango' )
					: sprintf(
						/* translators: %d: number of contact groups. */
						_n( 'Found %d group.', 'Found %d groups.', count( $groups ), 'texttango' ),
						count( $groups )
					),
			)
		);
	}

	/**
	 * Names and ids from a v2 contact-groups response.
	 *
	 * @param array<string, mixed> $response Decoded response body.
	 * @return array<int, array{id: string, name: string}>
	 */
	public static function parse_contact_groups( array $response ): array {
		$items = $response['data'] ?? $response;

		if ( ! is_array( $items ) ) {
			return array();
		}

		$groups = array();

		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$attributes = isset( $item['attributes'] ) && is_array( $item['attributes'] ) ? $item['attributes'] : $item;
			$id         = (string) ( $item['id'] ?? $attributes['uuid'] ?? '' );
			$name       = (string) ( $attributes['name'] ?? '' );

			if ( '' !== $id ) {
				$groups[] = array(
					'id'   => $id,
					'name' => '' !== $name ? $name : $id,
				);
			}
		}

		return $groups;
	}

	/**
	 * Chase delivery status for messages whose receipt never arrived (spec §19).
	 *
	 * The same sweep the scheduled job runs, triggered by hand for an admin who
	 * does not want to wait a quarter of an hour to find out.
	 */
	/**
	 * Bring an opt-in list over from another plugin.
	 */
	public function import_consent(): void {
		$this->authorize();

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in authorize(), above.
		$source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : '';

		if ( ! array_key_exists( $source, Importer::sources() ) ) {
			wp_send_json_error( array( 'message' => __( 'There is nothing to import from there.', 'texttango' ) ) );
		}

		wp_send_json_success( array( 'message' => Importer::summarise( Importer::import( $source ) ) ) );
	}

	/**
	 * Send one logged message again.
	 */
	public function resend_message(): void {
		$this->authorize();

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in authorize(), above.
		$id = isset( $_POST['id'] ) ? absint( wp_unslash( $_POST['id'] ) ) : 0;

		$result = Resend::again( $id );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( array( 'message' => $result->get_error_message() ) );
		}

		if ( ! empty( $result['sandboxed'] ) ) {
			wp_send_json_success(
				array( 'message' => __( 'Logged again, but not sent — sandbox mode is on.', 'texttango' ) )
			);
		}

		wp_send_json_success( array( 'message' => __( 'Queued to send again.', 'texttango' ) ) );
	}

	public function resync_pending(): void {
		$this->authorize();

		$result = Reconciler::run();

		if ( 0 === $result['checked'] ) {
			wp_send_json_success(
				array( 'message' => __( 'Nothing is waiting on a delivery receipt.', 'texttango' ) )
			);
		}

		wp_send_json_success(
			array(
				'message' => sprintf(
					/* translators: 1: number of messages checked, 2: number updated. */
					_n(
						'Checked %1$d message, updated %2$d.',
						'Checked %1$d messages, updated %2$d.',
						$result['checked'],
						'texttango'
					),
					$result['checked'],
					$result['updated']
				),
				'checked' => $result['checked'],
				'updated' => $result['updated'],
			)
		);
	}

	/**
	 * Send one real SMS to the admin's own number (spec §20).
	 *
	 * This deliberately **bypasses sandbox mode**. Its whole purpose is to prove
	 * the live path works — token, sender ID, and network — which a simulated send
	 * cannot do. The button therefore warns that it costs credits and requires a
	 * confirmation before it fires.
	 */
	public function send_test_sms(): void {
		$this->authorize();

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The nonce is verified by $this->authorize() above.
		$raw = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';

		if ( '' === trim( $raw ) ) {
			wp_send_json_error( array( 'message' => __( 'Enter the phone number to send the test to.', 'texttango' ) ) );
		}

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

		if ( is_wp_error( $recipient ) ) {
			$this->fail( $recipient );
		}

		// Remember the number so the field is prefilled next time.
		Settings::update( array( 'test_phone' => $recipient ) );

		$result = Sender::send(
			array(
				'to'             => $recipient,
				'message'        => self::test_message(),
				'event'          => 'test',
				'wp_user_id'     => get_current_user_id(),
				'bypass_sandbox' => true,
			)
		);

		if ( is_wp_error( $result ) ) {
			$this->fail( $result );
		}

		// Recorded because it spends real credit, bypasses sandbox, and is the one
		// send with a person's name attached rather than an event's.
		Recorder::manual_send( $recipient, __( 'settings screen', 'texttango' ) );

		wp_send_json_success(
			array(
				'message'   => sprintf(
					/* translators: %s: masked recipient number. */
					__( 'Test SMS sent to %s. Check the handset — delivery can take a few seconds.', 'texttango' ),
					PhoneNumber::mask( $recipient )
				),
				'uuid'      => $result['uuid'],
				'messageId' => $result['message_id'],
			)
		);
	}

	/**
	 * Body of the test message.
	 */
	private static function test_message(): string {
		return sprintf(
			/* translators: %s: the site name. */
			__( 'TextTango test message from %s. If you received this, your connection works.', 'texttango' ),
			wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES )
		);
	}

	/**
	 * Call `GET /v2/wallet` and report the live balance (spec §4).
	 */
	public function test_connection(): void {
		$this->authorize();

		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			$this->fail( $client );
		}

		$response = $client->wallet();

		if ( is_wp_error( $response ) ) {
			Settings::set_connection_state(
				array(
					'status'     => 'error',
					'code'       => $response->get_error_code(),
					'message'    => $response->get_error_message(),
					'checked_at' => time(),
				)
			);

			$this->fail( $response );
		}

		$wallet = self::parse_wallet( $response );

		/*
		 * The reading goes to BalanceWatch, which owns it. This used to be kept
		 * here as well, and the two drifted the moment anything spent credits:
		 * the hourly poll moved and this record did not, so the settings screen
		 * showed one figure while every other screen showed another.
		 *
		 * The connection record answers one question — does the connection work,
		 * and when was that last proven — and carrying a balance was never part
		 * of that job.
		 */
		BalanceWatch::remember( (float) $wallet['main'], (string) $wallet['currency'] );

		Settings::set_connection_state(
			array(
				'status'     => 'connected',
				'checked_at' => time(),
			)
		);

		wp_send_json_success(
			array(
				'message'  => sprintf(
					/* translators: %s: formatted account balance, e.g. "GHS 412.50". */
					__( 'Connected. Your balance is %s.', 'texttango' ),
					$wallet['formatted']
				),
				'balance'  => $wallet['formatted'],
				'bonus'    => $wallet['bonus_formatted'],
				'currency' => $wallet['currency'],
			)
		);
	}

	/**
	 * List approved sender IDs for the datalist on the settings screen.
	 */
	public function sender_ids(): void {
		$this->authorize();

		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			$this->fail( $client );
		}

		$response = $client->sender_ids();

		if ( is_wp_error( $response ) ) {
			$this->fail( $response );
		}

		$approved = self::parse_sender_ids( $response );

		wp_send_json_success(
			array(
				'senderIds' => $approved,
				'message'   => empty( $approved )
					? __( 'No approved sender IDs found on this account yet.', 'texttango' )
					: sprintf(
						/* translators: %d: number of approved sender IDs. */
						_n( 'Found %d approved sender ID.', 'Found %d approved sender IDs.', count( $approved ), 'texttango' ),
						count( $approved )
					),
			)
		);
	}

	/**
	 * Pull the balance out of a v2 wallet response.
	 *
	 * Written tolerantly on purpose: the envelope is documented but the exact
	 * attribute names are not pinned in the spec, and a settings screen that says
	 * "connected" is more useful than one that errors on an unexpected key.
	 *
	 * @param array<string, mixed> $response Decoded response body.
	 * @return array{main: float, bonus: float, currency: string, formatted: string, bonus_formatted: string}
	 */
	public static function parse_wallet( array $response ): array {
		$attributes = $response['data']['attributes'] ?? $response['data'] ?? $response;
		$attributes = is_array( $attributes ) ? $attributes : array();

		$currency = '';

		foreach ( array( 'currency', 'currency_code' ) as $key ) {
			if ( ! empty( $attributes[ $key ] ) && is_string( $attributes[ $key ] ) ) {
				$currency = strtoupper( $attributes[ $key ] );
				break;
			}
		}

		if ( '' === $currency ) {
			$currency = 'GHS';
		}

		$main  = self::first_numeric( $attributes, array( 'main_balance', 'balance', 'main', 'amount' ) );
		$bonus = self::first_numeric( $attributes, array( 'bonus_balance', 'bonus' ) );

		return array(
			'main'            => $main,
			'bonus'           => $bonus,
			'currency'        => $currency,
			'formatted'       => $currency . ' ' . number_format_i18n( $main, 2 ),
			'bonus_formatted' => $currency . ' ' . number_format_i18n( $bonus, 2 ),
		);
	}

	/**
	 * Approved sender IDs from a v2 sender-ids response (spec §4).
	 *
	 * @param array<string, mixed> $response Decoded response body.
	 * @return string[]
	 */
	public static function parse_sender_ids( array $response ): array {
		$items = $response['data'] ?? $response;

		if ( ! is_array( $items ) ) {
			return array();
		}

		$approved = array();

		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$attributes = isset( $item['attributes'] ) && is_array( $item['attributes'] ) ? $item['attributes'] : $item;

			$name   = $attributes['name'] ?? $attributes['sender_id'] ?? '';
			$status = strtolower( (string) ( $attributes['status'] ?? '' ) );

			if ( is_string( $name ) && '' !== $name && 'approved' === $status ) {
				$approved[] = $name;
			}
		}

		return array_values( array_unique( $approved ) );
	}

	/**
	 * First numeric value among a set of candidate keys.
	 *
	 * @param array<string, mixed> $data Source array.
	 * @param string[]             $keys Keys to try, in order.
	 */
	private static function first_numeric( array $data, array $keys ): float {
		foreach ( $keys as $key ) {
			if ( isset( $data[ $key ] ) && is_numeric( $data[ $key ] ) ) {
				return (float) $data[ $key ];
			}
		}

		return 0.0;
	}

	/**
	 * Reject the request unless it is nonced and the user is permitted.
	 */
	private function authorize(): void {
		if ( ! check_ajax_referer( Menu::AJAX_NONCE, 'nonce', false ) ) {
			wp_send_json_error(
				array( 'message' => __( 'Your session expired. Reload the page and try again.', 'texttango' ) ),
				403
			);
		}

		if ( ! current_user_can( Activator::CAPABILITY ) ) {
			wp_send_json_error(
				array( 'message' => __( 'You do not have permission to do that.', 'texttango' ) ),
				403
			);
		}
	}

	/**
	 * Return an error to the browser and stop.
	 *
	 * @param WP_Error $error Error to report.
	 */
	private function fail( WP_Error $error ): void {
		$data   = $error->get_error_data();
		$status = is_array( $data ) && ! empty( $data['status'] ) ? (int) $data['status'] : 0;

		wp_send_json_error(
			array(
				'message' => $error->get_error_message(),
				'code'    => $error->get_error_code(),
				'status'  => $status,
			)
		);
	}
}
