<?php
/**
 * Bringing consent over from another SMS plugin.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Install;

use TextTango\Api\PhoneNumber;
use TextTango\Audit\AuditLog;
use TextTango\Consent\Consent;
use TextTango\Consent\ConsentList;
use TextTango\Consent\Suppressions;
use TextTango\Otp\UserRepository;

defined( 'ABSPATH' ) || exit;

/**
 * Importing an existing subscriber list (spec §20).
 *
 * ### What is worth importing, and what is not
 *
 * Only one thing: **who agreed**. Settings do not transfer — a different
 * gateway has different credentials and different sender rules. Message
 * wording does not transfer, because the merge tags are another plugin's.
 * Message history does not transfer, because it is a record of sends this site
 * did not make and cannot prove.
 *
 * Consent is different. It is the expensive thing to rebuild, the thing a
 * merchant cannot recreate by asking again without annoying everybody, and the
 * one thing that stops this plugin being useless on day one of a migration.
 *
 * ### Which plugins have anything to import
 *
 * **WP SMS** keeps a genuine subscriber list — `{prefix}sms_subscribes`, where
 * `status = 1` means the number confirmed. That is an opt-in and it imports.
 *
 * **SMS Alert** does not. It reads `billing_phone` and WooCommerce orders,
 * which is where this plugin already looks, so there is nothing to move. Saying
 * so is more useful than an importer that reports zero and leaves somebody
 * wondering what went wrong.
 *
 * ### What the import will not do
 *
 * It will not invent consent. A row still waiting for confirmation (`status =
 * 0`) is not an opt-in and is skipped. A number on this site's opt-out list is
 * skipped even if the other plugin thought it was subscribed, because a person
 * who said stop outranks a database that has not caught up.
 *
 * And it does not claim the consent was collected here. The source and the
 * original date come across, so the trail says "WP SMS, 14 March" rather than
 * "this plugin, today" — which is the difference between a record and a story.
 */
final class Importer {

	/**
	 * WP SMS.
	 */
	public const SOURCE_WP_SMS = 'wp-sms';

	/**
	 * Consent source recorded against an imported opt-in.
	 *
	 * Prefixed so the trail can never be mistaken for consent this plugin
	 * collected itself. An underscore rather than a colon because
	 * {@see Consent::write_user()} runs the source through `sanitize_key()`,
	 * which silently drops a colon — leaving `importwp-sms`, which reads like a
	 * typo rather than like a provenance.
	 */
	public const SOURCE_PREFIX = 'import_';

	/**
	 * How many rows are examined per run.
	 */
	public const BATCH = 5000;

	/**
	 * What can be imported from where, and whether anything is there.
	 *
	 * @return array<string, array{label: string, present: bool, rows: int}>
	 */
	public static function sources(): array {
		return array(
			self::SOURCE_WP_SMS => array(
				'label'   => __( 'WP SMS', 'texttango' ),
				'present' => self::has_wp_sms(),
				'rows'    => self::has_wp_sms() ? self::count_wp_sms() : 0,
			),
		);
	}

	/**
	 * Whether WP SMS has left a subscriber table behind.
	 *
	 * The table, not the plugin. A merchant who has already deactivated it still
	 * has the list, and that is exactly when they want this.
	 */
	public static function has_wp_sms(): bool {
		global $wpdb;

		$table = $wpdb->prefix . 'sms_subscribes';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix; checking existence.
		return $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
	}

	/**
	 * How many confirmed subscribers WP SMS holds.
	 */
	public static function count_wp_sms(): int {
		global $wpdb;

		$table = $wpdb->prefix . 'sms_subscribes';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix.
		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE status = 1" );
	}

	/**
	 * Read WP SMS's confirmed subscribers.
	 *
	 * @return array<int, array{mobile: string, date: string}>
	 */
	public static function read_wp_sms(): array {
		global $wpdb;

		$table = $wpdb->prefix . 'sms_subscribes';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is $wpdb->prefix joined to a literal; another plugin's table 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 mobile, date FROM {$table} WHERE status = 1 ORDER BY ID ASC LIMIT %d",
				self::BATCH
			),
			ARRAY_A
		);

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

	/**
	 * What an import would do, without doing it.
	 *
	 * @param string $source One of the SOURCE_* constants.
	 * @return array<string, int>
	 */
	public static function preview( string $source ): array {
		return self::run( $source, false );
	}

	/**
	 * Import, and record what happened.
	 *
	 * @param string $source One of the SOURCE_* constants.
	 * @return array<string, int>
	 */
	public static function import( string $source ): array {
		$report = self::run( $source, true );

		AuditLog::record(
			AuditLog::ACTION_CONSENT,
			/* translators: %s: the plugin the list came from. */
			sprintf( __( 'Imported from %s', 'texttango' ), $source ),
			$report
		);

		return $report;
	}

	/**
	 * The import itself.
	 *
	 * @param string $source One of the SOURCE_* constants.
	 * @param bool   $write  Whether to actually record consent.
	 * @return array<string, int> {
	 *     @type int $read       Rows examined.
	 *     @type int $imported   Consent recorded against an account.
	 *     @type int $listed     Recorded against the number alone, no account.
	 *     @type int $already    Already on record here.
	 *     @type int $suppressed Skipped for being on the opt-out list.
	 *     @type int $unusable   Skipped for not being a valid number.
	 * }
	 */
	private static function run( string $source, bool $write ): array {
		$report = array(
			'read'       => 0,
			'imported'   => 0,
			'listed'     => 0,
			'already'    => 0,
			'suppressed' => 0,
			'unusable'   => 0,
		);

		if ( self::SOURCE_WP_SMS !== $source || ! self::has_wp_sms() ) {
			return $report;
		}

		$index = self::phone_index();

		foreach ( self::read_wp_sms() as $row ) {
			++$report['read'];

			$normalized = PhoneNumber::normalize( (string) ( $row['mobile'] ?? '' ) );

			if ( is_wp_error( $normalized ) ) {
				++$report['unusable'];

				continue;
			}

			/*
			 * Checked before the account lookup, and deliberately: somebody who
			 * opted out here must not have that decision overwritten by another
			 * plugin's older idea of what they wanted.
			 */
			if ( Suppressions::contains( $normalized ) ) {
				++$report['suppressed'];

				continue;
			}

			$user_id = $index[ $normalized ] ?? null;

			if ( null === $user_id ) {
				/*
				 * No account, and until the consent list existed that was the end
				 * of it — this branch could only count them and say so, which on a
				 * real migration was most of the list. The number is now a record
				 * in its own right, exactly as an opt-out already was.
				 */
				if ( ConsentList::contains( $normalized ) ) {
					++$report['already'];

					continue;
				}

				if ( $write ) {
					ConsentList::add( $normalized, self::SOURCE_PREFIX . $source );
				}

				++$report['listed'];

				continue;
			}

			if ( Consent::user_has_consented( $user_id ) ) {
				++$report['already'];

				continue;
			}

			if ( $write ) {
				Consent::record_for_user( $user_id, true, self::SOURCE_PREFIX . $source );
			}

			++$report['imported'];
		}

		return $report;
	}

	/**
	 * Every number on this site, mapped to the account holding it.
	 *
	 * Built once in a single query rather than looked up per row. An import of
	 * five thousand subscribers is five thousand `get_users()` calls otherwise,
	 * each a meta query — which on a real store is the difference between a
	 * button that works and one that times out.
	 *
	 * Both keys are read because they answer different questions. The verified
	 * number is the one this plugin attached; the billing phone is the one the
	 * customer typed at checkout, is not normalized, and is far more likely to
	 * be the one another plugin's subscriber list matches.
	 *
	 * @return array<string, int> E.164 number mapped to a user id.
	 */
	public static function phone_index(): array {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One bounded read, run only when an import is being prepared.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT user_id, meta_value FROM {$wpdb->usermeta}
				 WHERE meta_key IN ( %s, %s ) AND meta_value != ''
				 ORDER BY meta_key = %s DESC
				 LIMIT %d",
				UserRepository::META_PHONE,
				'billing_phone',
				UserRepository::META_PHONE,
				self::BATCH
			),
			ARRAY_A
		);

		$index = array();

		foreach ( (array) $rows as $row ) {
			$normalized = PhoneNumber::normalize( (string) $row['meta_value'] );

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

			// The verified number is read first, so it wins where an account has
			// both and they disagree — it is the one somebody proved they hold.
			if ( ! isset( $index[ $normalized ] ) ) {
				$index[ $normalized ] = (int) $row['user_id'];
			}
		}

		return $index;
	}

	/**
	 * An import report in one sentence.
	 *
	 * Pure, so the wording of every combination — including the awkward one
	 * where nothing could be imported at all — is decided in one place.
	 *
	 * @param array<string, int> $report Report from {@see self::import()}.
	 */
	public static function summarise( array $report ): string {
		if ( 0 === (int) ( $report['read'] ?? 0 ) ) {
			return __( 'There was nothing to import.', 'texttango' );
		}

		$parts = array(
			sprintf(
				/* translators: %s: number of people. */
				_n( 'Consent recorded for %s person.', 'Consent recorded for %s people.', (int) $report['imported'], 'texttango' ),
				number_format_i18n( (int) $report['imported'] )
			),
		);

		foreach ( array( 'listed', 'already', 'suppressed', 'unusable' ) as $key ) {
			$count = (int) ( $report[ $key ] ?? 0 );

			if ( $count > 0 ) {
				$parts[] = self::skip_text( $key, $count );
			}
		}

		return implode( ' ', $parts );
	}

	/**
	 * Why some rows were skipped.
	 *
	 * Each reason is written twice rather than once with a count substituted,
	 * because "1 have no account" is the kind of sentence that makes a merchant
	 * trust the rest of the screen a little less.
	 *
	 * @param string $reason What was skipped.
	 * @param int    $count  How many.
	 */
	public static function skip_text( string $reason, int $count ): string {
		$number = number_format_i18n( $count );

		if ( 'already' === $reason ) {
			return sprintf(
				/* translators: %s: number of people. */
				_n( '%s already had consent on record.', '%s already had consent on record.', $count, 'texttango' ),
				$number
			);
		}

		if ( 'listed' === $reason ) {
			return sprintf(
				/* translators: %s: number of people. */
				_n(
					'%s has no account here, so their consent was recorded against the number itself.',
					'%s have no account here, so their consent was recorded against the numbers themselves.',
					$count,
					'texttango'
				),
				$number
			);
		}

		if ( 'suppressed' === $reason ) {
			return sprintf(
				/* translators: %s: number of people. */
				_n(
					'%s is on your opt-out list and was left alone.',
					'%s are on your opt-out list and were left alone.',
					$count,
					'texttango'
				),
				$number
			);
		}

		return sprintf(
			/* translators: %s: number of entries. */
			_n( '%s had a number that could not be read.', '%s had numbers that could not be read.', $count, 'texttango' ),
			$number
		);
	}
}
