<?php
/**
 * Nightly repair of the contact group.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Contacts;

use TextTango\Api\Client;
use TextTango\Consent\Consent;
use TextTango\Consent\Recipient;
use TextTango\Consent\Suppressions;
use TextTango\Install\Schema;
use TextTango\Sending\Queue;
use TextTango\Support\Clock;
use WC_Order;

defined( 'ABSPATH' ) || exit;

/**
 * Puts the TextTango group back in step with who has actually consented (spec §7).
 *
 * Sync is event-driven: consent changes, a job runs, the platform is told. That
 * is the right shape, but it has one failure mode — the job runs while the API is
 * unreachable, the token has expired, or the site is mid-deploy. Nothing retries
 * it, because the trigger has already fired and will not fire again. The result
 * is a customer who agreed and was never added, or one who withdrew and was never
 * removed. The second is the one that matters: it means marketing a person who
 * asked to be left alone.
 *
 * So this sweeps for both, nightly, and fixes what it finds.
 *
 * ### Why the bulk create endpoint is not used
 *
 * The spec expected `POST /v2/contacts/bulk`, and it looks made for this. It is
 * not safe for it. That endpoint inserts with `insertOrIgnore`, which needs a
 * unique index to ignore *on*, and the contacts table has one only on the row's
 * own generated id — nothing covering the number. So nothing is ever ignored:
 * every call inserts. It also returns a count of what was submitted rather than
 * what was written, and no ids at all, so there is no way to learn what was
 * created. Reconciling with it would duplicate the entire group every night and
 * lose track of it while doing so.
 *
 * Pushing therefore reuses the ordinary single-contact path, which *is*
 * idempotent: a create that clashes comes back 409, the id is looked up, and the
 * link is learned. Removals do use the bulk endpoint — deleting by id is
 * idempotent, and there is real saving in one call rather than a hundred.
 *
 * ### Bounded on purpose
 *
 * A run touches at most {@see self::BATCH} customers of each kind. A store with
 * a large backlog clears it over several nights rather than spending an hour and
 * a rate limit on the first one. Because fixing a customer takes them out of the
 * set the next run looks at, the backlog drains rather than circling.
 */
final class ContactReconciler {

	/**
	 * Action Scheduler hook for the sweep.
	 */
	public const HOOK = 'texttango_reconcile_contacts';

	/**
	 * How often it runs.
	 */
	public const INTERVAL = DAY_IN_SECONDS;

	/**
	 * Customers of each kind touched per run.
	 */
	public const BATCH = 200;

	/**
	 * UUIDs per bulk delete. The API's own ceiling is 1000.
	 */
	public const DELETE_CHUNK = 500;

	/**
	 * Option holding what the last run did.
	 */
	public const OPTION_REPORT = 'texttango_reconcile_report';

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( self::HOOK, array( self::class, 'run' ) );
		add_action( 'init', array( self::class, 'ensure_scheduled' ) );
	}

	/**
	 * Make sure the nightly sweep exists.
	 */
	public static function ensure_scheduled(): void {
		if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) {
			return;
		}

		if ( as_has_scheduled_action( self::HOOK, array(), Queue::GROUP ) ) {
			return;
		}

		as_schedule_recurring_action( time() + self::INTERVAL, self::INTERVAL, self::HOOK, array(), Queue::GROUP );
	}

	/**
	 * Sweep once.
	 *
	 * @return array<string, int> What was found and done.
	 */
	public static function run(): array {
		$report = array(
			'pushed'    => 0,
			'removed'   => 0,
			'failed'    => 0,
			'truncated' => 0,
		);

		if ( ! ContactSync::is_enabled() ) {
			return self::record( $report );
		}

		$stale = array_merge( self::stale_users(), self::stale_orders(), self::stale_numbers() );

		$report['removed'] = self::remove( $stale );

		$missing = array_merge( self::unsynced_users(), self::unsynced_orders(), self::unsynced_numbers() );

		$report['pushed'] = self::push( $missing );
		$report['failed'] = count( $missing ) - $report['pushed'];

		// Said out loud rather than left to look like a clean sweep: a full batch
		// means there is more waiting, and the next run will take the rest.
		if ( count( $stale ) >= self::BATCH || count( $missing ) >= self::BATCH ) {
			$report['truncated'] = 1;
		}

		return self::record( $report );
	}

	/**
	 * What a customer needs doing to them.
	 *
	 * Pure, so the rule can be read and tested on its own. Suppression outranks
	 * consent: somebody the platform is refusing must come out of the group even
	 * if they once agreed, because leaving them there is how a campaign ends up
	 * addressed to a person who opted out.
	 *
	 * @param bool $consented  Whether marketing consent is confirmed.
	 * @param bool $suppressed Whether the number is on the opt-out list.
	 * @param bool $linked     Whether a contact UUID is already stored.
	 * @return string One of 'push', 'remove', 'nothing'.
	 */
	public static function decide( bool $consented, bool $suppressed, bool $linked ): string {
		if ( $suppressed || ! $consented ) {
			return $linked ? 'remove' : 'nothing';
		}

		return $linked ? 'nothing' : 'push';
	}

	/**
	 * Registered customers who are linked but should not be.
	 *
	 * @return array<int, array{kind: string, id: int, uuid: string}>
	 */
	private static function stale_users(): array {
		$found = array();

		$users = get_users(
			array(
				'meta_key'     => ContactRepository::META_UUID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Indexed by WordPress, and this runs nightly on cron.
				'meta_compare' => 'EXISTS',
				'number'       => self::BATCH,
				'fields'       => 'ID',
				'orderby'      => 'ID',
			)
		);

		foreach ( $users as $user_id ) {
			$user_id = (int) $user_id;
			$uuid    = ContactRepository::uuid_for_user( $user_id );

			if ( '' === $uuid ) {
				continue;
			}

			$phone = Recipient::for_user( $user_id );

			$verdict = self::decide(
				Consent::user_has_consented( $user_id ),
				'' !== $phone && Suppressions::contains( $phone ),
				true
			);

			if ( 'remove' === $verdict ) {
				$found[] = array(
					'kind' => 'user',
					'id'   => $user_id,
					'uuid' => $uuid,
				);
			}
		}

		return $found;
	}

	/**
	 * Registered customers who consented but were never added.
	 *
	 * @return array<int, array{kind: string, id: int}>
	 */
	private static function unsynced_users(): array {
		$found = array();

		$users = get_users(
			array(
				'number'     => self::BATCH,
				'fields'     => 'ID',
				'orderby'    => 'ID',
				'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Nightly, on cron, and bounded by BATCH.
					'relation' => 'AND',
					array(
						'key'   => Consent::META_OPTIN,
						'value' => '1',
					),
					array(
						'key'     => ContactRepository::META_UUID,
						'compare' => 'NOT EXISTS',
					),
				),
			)
		);

		foreach ( $users as $user_id ) {
			$found[] = array(
				'kind' => 'user',
				'id'   => (int) $user_id,
			);
		}

		return $found;
	}

	/**
	 * Numbers on the consent list that are linked but should not be.
	 *
	 * Only suppression can make one stale. A row's presence is the consent, so
	 * unlike a user or an order there is no third state where it exists but has
	 * withdrawn — withdrawing means the row is gone, and a gone row is handled by
	 * the same query finding nothing to link.
	 *
	 * @return array<int, array{kind: string, id: int, uuid: string}>
	 */
	private static function stale_numbers(): array {
		$found = array();

		foreach ( self::linked_numbers() as $row ) {
			$verdict = self::decide(
				true,
				Suppressions::contains( (string) $row['recipient'] ),
				'' !== (string) $row['contact_uuid']
			);

			if ( 'remove' === $verdict ) {
				$found[] = array(
					'kind' => 'number',
					'id'   => (int) $row['id'],
					'uuid' => (string) $row['contact_uuid'],
				);
			}
		}

		return $found;
	}

	/**
	 * Numbers on the consent list that should be in the group and are not.
	 *
	 * @return array<int, array{kind: string, id: int}>
	 */
	private static function unsynced_numbers(): array {
		global $wpdb;

		$table = Schema::consents_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name is built from $wpdb->prefix and cannot be parameterised.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated, from $wpdb->prefix.
				"SELECT id, recipient FROM {$table} WHERE contact_uuid = '' ORDER BY id ASC LIMIT %d",
				self::BATCH
			),
			ARRAY_A
		);

		$found = array();

		foreach ( (array) $rows as $row ) {
			if ( Suppressions::contains( (string) $row['recipient'] ) ) {
				continue;
			}

			$found[] = array(
				'kind' => 'number',
				'id'   => (int) $row['id'],
			);
		}

		return $found;
	}

	/**
	 * Consent list rows that already carry a contact link.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private static function linked_numbers(): array {
		global $wpdb;

		$table = Schema::consents_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name is built from $wpdb->prefix and cannot be parameterised.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table name is interpolated, from $wpdb->prefix.
				"SELECT id, recipient, contact_uuid FROM {$table} WHERE contact_uuid != '' ORDER BY id ASC LIMIT %d",
				self::BATCH
			),
			ARRAY_A
		);

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Guest orders that are linked but should not be.
	 *
	 * @return array<int, array{kind: string, id: int, uuid: string}>
	 */
	private static function stale_orders(): array {
		$found = array();

		foreach ( self::guest_orders( ContactRepository::ORDER_META_UUID, 'EXISTS' ) as $order ) {
			$uuid = ContactRepository::uuid_for_order( $order );

			if ( '' === $uuid ) {
				continue;
			}

			$phone = Recipient::for_order( $order );

			$verdict = self::decide(
				Consent::STATE_GRANTED === Consent::state_for_order( $order ),
				'' !== $phone && Suppressions::contains( $phone ),
				true
			);

			if ( 'remove' === $verdict ) {
				$found[] = array(
					'kind' => 'order',
					'id'   => $order->get_id(),
					'uuid' => $uuid,
				);
			}
		}

		return $found;
	}

	/**
	 * Guest orders that consented but were never added.
	 *
	 * @return array<int, array{kind: string, id: int}>
	 */
	private static function unsynced_orders(): array {
		$found = array();

		foreach ( self::guest_orders( Consent::ORDER_META_OPTIN, '=', 'yes' ) as $order ) {
			if ( '' !== ContactRepository::uuid_for_order( $order ) ) {
				continue;
			}

			$found[] = array(
				'kind' => 'order',
				'id'   => $order->get_id(),
			);
		}

		return $found;
	}

	/**
	 * Orders placed without an account, matching one meta condition.
	 *
	 * Guests only: a customer with an account is reconciled through their account,
	 * and doing both would push the same person twice under two different records.
	 *
	 * @param string $key     Meta key.
	 * @param string $compare Comparison operator.
	 * @param string $value   Value to compare against, when the operator needs one.
	 * @return WC_Order[]
	 */
	private static function guest_orders( string $key, string $compare, string $value = '' ): array {
		if ( ! function_exists( 'wc_get_orders' ) ) {
			return array();
		}

		$condition = array(
			'key'     => $key,
			'compare' => $compare,
		);

		if ( '' !== $value ) {
			$condition['value'] = $value;
		}

		$orders = wc_get_orders(
			array(
				'limit'       => self::BATCH,
				'customer_id' => 0,
				'orderby'     => 'ID',
				'order'       => 'ASC',
				'return'      => 'objects',
				'meta_query'  => array( $condition ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Nightly, on cron, and bounded by BATCH.
			)
		);

		return array_filter(
			(array) $orders,
			static fn( $order ): bool => $order instanceof WC_Order
		);
	}

	/**
	 * Remove contacts that should no longer be in the group.
	 *
	 * @param array<int, array{kind: string, id: int, uuid: string}> $subjects Customers to unlink.
	 * @return int How many were removed.
	 */
	private static function remove( array $subjects ): int {
		if ( empty( $subjects ) ) {
			return 0;
		}

		$client = Client::from_settings();

		if ( is_wp_error( $client ) ) {
			return 0;
		}

		$removed = 0;

		foreach ( array_chunk( $subjects, self::DELETE_CHUNK ) as $chunk ) {
			$uuids  = array_column( $chunk, 'uuid' );
			$result = $client->delete_contacts( $uuids );

			if ( is_wp_error( $result ) ) {
				continue;
			}

			/*
			 * Forgotten locally only once the platform has confirmed. Clearing the
			 * link on a failed call would strand the contact there with nothing left
			 * pointing at it, and the next sweep would not know to try again.
			 */
			foreach ( $chunk as $subject ) {
				self::forget( $subject );
				++$removed;
			}
		}

		return $removed;
	}

	/**
	 * Add customers who consented but were never pushed.
	 *
	 * @param array<int, array{kind: string, id: int}> $subjects Customers to add.
	 * @return int How many are now linked.
	 */
	private static function push( array $subjects ): int {
		$pushed = 0;

		foreach ( $subjects as $subject ) {
			ContactSync::run( $subject['kind'], $subject['id'] );

			if ( '' !== self::uuid_of( $subject ) ) {
				++$pushed;
			}
		}

		return $pushed;
	}

	/**
	 * Drop a stored contact link.
	 *
	 * @param array{kind: string, id: int} $subject Customer.
	 */
	private static function forget( array $subject ): void {
		if ( 'order' === $subject['kind'] ) {
			$order = wc_get_order( $subject['id'] );

			if ( $order instanceof WC_Order ) {
				ContactRepository::set_uuid_for_order( $order, '' );
			}

			return;
		}

		ContactRepository::set_uuid_for_user( $subject['id'], '' );
	}

	/**
	 * The contact UUID currently stored for a customer.
	 *
	 * @param array{kind: string, id: int} $subject Customer.
	 */
	private static function uuid_of( array $subject ): string {
		if ( 'order' === $subject['kind'] ) {
			$order = wc_get_order( $subject['id'] );

			return $order instanceof WC_Order ? ContactRepository::uuid_for_order( $order ) : '';
		}

		return ContactRepository::uuid_for_user( $subject['id'] );
	}

	/**
	 * Store what the run did, so the settings screen can show it.
	 *
	 * @param array<string, int> $report Counts.
	 * @return array<string, int>
	 */
	private static function record( array $report ): array {
		$report['at'] = Clock::now();

		update_option( self::OPTION_REPORT, $report, false );

		return $report;
	}

	/**
	 * What the last run did.
	 *
	 * @return array<string, int>
	 */
	public static function report(): array {
		$stored = get_option( self::OPTION_REPORT, array() );

		return is_array( $stored ) ? $stored : array();
	}
}
