<?php
/**
 * Sending on a form submission.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Forms;

use TextTango\Api\PhoneNumber;
use TextTango\Consent\ConsentList;
use TextTango\Messages\MessageLog;
use TextTango\Sending\Sender;
use TextTango\Support\Clock;
use TextTango\WooCommerce\Events;
use TextTango\WooCommerce\Templates;

defined( 'ABSPATH' ) || exit;

/**
 * Form triggers (spec §21).
 *
 * ### The two events, and why they are named this way
 *
 * Both `Consent::is_marketing()` and `Budget::tier()` classify by prefix, so an
 * event's name decides whether it needs marketing consent and which spend
 * ceiling it sheds under. `form_` puts both of these on the transactional side,
 * which is the deliberate position:
 *
 * - **The staff alert** goes to the shop's own numbers. Requiring consent from
 *   a merchant to be told about their own form would be nonsense, and it is
 *   listed as always allowed for the same reason `new_order_admin` is.
 * - **The reply to the person who submitted** is a service message on the same
 *   argument that makes an order update one: they initiated the exchange
 *   moments ago by typing their number into a form and pressing send. It is a
 *   confirmation of a thing they just did, not an approach.
 *
 * Marketing captured *on* a form is a different matter entirely and takes a
 * different path — it writes to the consent list and sends nothing at all.
 * These two must not be conflated, which is why consent capture is not an event.
 */
final class Forms {

	/**
	 * Event id for the alert to staff.
	 */
	public const EVENT_ALERT = 'form_submitted_admin';

	/**
	 * Event id for the reply to whoever submitted.
	 */
	public const EVENT_REPLY = 'form_submitted_reply';

	/**
	 * Where consent captured on a form is recorded as having come from.
	 */
	public const CONSENT_SOURCE = 'form';

	/**
	 * The adapters, in the order the settings screen lists them.
	 *
	 * @return array<string, class-string<Provider>>
	 */
	public static function providers(): array {
		return array(
			ContactForm7::slug() => ContactForm7::class,
			WPForms::slug()      => WPForms::class,
			GravityForms::slug() => GravityForms::class,
			FluentForms::slug()  => FluentForms::class,
		);
	}

	/**
	 * Only the adapters whose plugin is actually installed.
	 *
	 * @return array<string, class-string<Provider>>
	 */
	public static function active_providers(): array {
		return array_filter(
			self::providers(),
			static fn( string $provider ): bool => $provider::is_active()
		);
	}

	/**
	 * Hook every active provider.
	 *
	 * Inactive ones are never constructed, so a form plugin that is not present
	 * costs nothing and cannot fatal on a function it does not define.
	 */
	public function register(): void {
		foreach ( self::active_providers() as $provider ) {
			( new $provider() )->register();
		}
	}

	/**
	 * Act on one submission.
	 *
	 * Every adapter ends here. Nothing happens for a form the merchant has not
	 * configured, which is the default for every form on the site.
	 *
	 * @param Submission $submission The submission.
	 */
	public static function handle( Submission $submission ): void {
		/*
		 * One submission is acted on once, however many hooks announce it.
		 *
		 * Fluent Forms is the reason: this plugin listens on both
		 * `fluentform/submission_inserted` and the pre-5 `fluentform_submission_inserted`,
		 * since a site can be on either, and a version firing both would run this
		 * twice for one form being sent.
		 *
		 * The dedupe key would usually absorb that — it hashes the answers — but
		 * only while both firings land in the same second, and "usually" is not a
		 * guarantee worth resting a duplicate charge on. Two firings of one
		 * submission are always in one request, so remembering it here settles it
		 * outright.
		 */
		static $handled = array();

		$once = $submission->key() . ':' . md5( (string) wp_json_encode( $submission->fields() ) );

		if ( isset( $handled[ $once ] ) ) {
			return;
		}

		$handled[ $once ] = true;

		$config = FormSettings::for_form( $submission->key() );

		if ( empty( $config['enabled'] ) ) {
			return;
		}

		$number = self::number_from( $config, $submission );

		/*
		 * Consent first, and before any send. If the same submission both grants
		 * consent and triggers a reply, the record has to exist before anything
		 * consults it — otherwise the first message after somebody opts in is the
		 * one message sent without knowing they had.
		 */
		if ( '' !== $number && FormSettings::captures_consent( $config, $submission ) ) {
			ConsentList::add( $number, self::CONSENT_SOURCE );
		}

		if ( FormSettings::alerts( $config ) ) {
			self::alert_staff( $config, $submission );
		}

		if ( '' !== $number && FormSettings::replies( $config ) ) {
			self::reply( $config, $submission, $number );
		}
	}

	/**
	 * The submitted number, normalised, or an empty string.
	 *
	 * A visitor types a phone number however they like, and most of those spellings
	 * are not sendable. An unusable one is not an error worth surfacing to them —
	 * their form submitted fine, and the merchant still has the entry.
	 *
	 * @param array<string, mixed> $config     One form's configuration.
	 * @param Submission           $submission The submission.
	 */
	private static function number_from( array $config, Submission $submission ): string {
		$field = trim( (string) ( $config['phone_field'] ?? '' ) );

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

		$raw = trim( $submission->field( $field ) );

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

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

		return is_wp_error( $number ) ? '' : (string) $number;
	}

	/**
	 * Tell staff a submission arrived.
	 *
	 * @param array<string, mixed> $config     One form's configuration.
	 * @param Submission           $submission The submission.
	 */
	private static function alert_staff( array $config, Submission $submission ): void {
		$body = Templates::render( self::template( $config, 'alert_template', self::default_alert() ), $submission->tags() );

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

		// Already normalized and made distinct by admin_recipients(), so one
		// handset written two ways cannot become two messages here.
		foreach ( Events::admin_recipients() as $index => $recipient ) {
			Sender::queue(
				array(
					'to'         => $recipient,
					'message'    => $body,
					'event'      => self::EVENT_ALERT,
					'dedupe_key' => self::dedupe_key( self::EVENT_ALERT, $submission, (string) $index ),
				)
			);
		}
	}

	/**
	 * Reply to whoever submitted the form.
	 *
	 * @param array<string, mixed> $config     One form's configuration.
	 * @param Submission           $submission The submission.
	 * @param string               $number     The submitter's number, in E.164.
	 */
	private static function reply( array $config, Submission $submission, string $number ): void {
		$body = Templates::render( self::template( $config, 'reply_template', self::default_reply() ), $submission->tags() );

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

		/*
		 * No consent check here, deliberately. `Sender` consults
		 * `Consent::decide()` for every message that passes through it, and a
		 * second check in this file could only ever drift from that one — the
		 * opt-out list would then be honoured differently depending on which
		 * route a message took, which is precisely what a single gate prevents.
		 */
		Sender::queue(
			array(
				'to'         => $number,
				'message'    => $body,
				'event'      => self::EVENT_REPLY,
				'dedupe_key' => self::dedupe_key( self::EVENT_REPLY, $submission, $number ),
			)
		);
	}

	/**
	 * What makes one form message distinct from another.
	 *
	 * The timestamp alone is not enough, and the way it fails is quiet. A form on
	 * a busy page takes two submissions in the same second, `Clock::now()` has
	 * one-second resolution, and the second person's alert is deduplicated away
	 * as though it were a retry — the shop never hears about an enquiry that
	 * arrived. Found exactly that way: three submissions in one second during a
	 * live probe produced one message.
	 *
	 * Hashing the answers fixes it and keeps the protection worth having: two
	 * different enquiries in the same second differ in content and both send,
	 * while a double-clicked submit button produces the same content and is
	 * still caught.
	 *
	 * @param string     $event      Event id.
	 * @param Submission $submission The submission.
	 * @param string     $scope      What distinguishes this copy — a staff index,
	 *                               or the recipient.
	 */
	private static function dedupe_key( string $event, Submission $submission, string $scope ): string {
		return MessageLog::build_dedupe_key(
			$event,
			$submission->key(),
			$scope . ':' . (string) Clock::now() . ':' . md5( (string) wp_json_encode( $submission->fields() ) )
		);
	}

	/**
	 * A configured template, or the default when it is blank.
	 *
	 * @param array<string, mixed> $config   One form's configuration.
	 * @param string               $setting  Which template.
	 * @param string               $fallback The default.
	 */
	private static function template( array $config, string $setting, string $fallback ): string {
		$template = trim( (string) ( $config[ $setting ] ?? '' ) );

		return '' === $template ? $fallback : $template;
	}

	/**
	 * The default staff alert.
	 */
	public static function default_alert(): string {
		return __( 'New submission on {form_name} at {site_name}.', 'texttango' );
	}

	/**
	 * The default reply to whoever submitted.
	 */
	public static function default_reply(): string {
		return __( 'Thanks for getting in touch with {site_name}. We have your message and will reply shortly.', 'texttango' );
	}
}
