<?php
/**
 * WordPress personal-data export and erasure.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Privacy;

use TextTango\Api\PhoneNumber;
use TextTango\Consent\Consent;
use TextTango\Consent\ConsentList;
use TextTango\Consent\Suppressions;
use TextTango\Marketing\PersonalCampaign;
use TextTango\Contacts\ContactRepository;
use TextTango\Install\Schema;
use TextTango\Otp\UserRepository;

defined( 'ABSPATH' ) || exit;

/**
 * Plugs this plugin's data into WordPress's own privacy tools (spec §22).
 *
 * The plugin holds a fair amount about a person: their number, when they proved
 * it, what they agreed to and when, every message sent to them, and their id on
 * the platform. A data-subject request that returned none of that would be
 * answered wrongly, so all of it is exported and erased through the built-in
 * flows rather than needing a separate process.
 *
 * **Suppression records are deliberately kept.** They exist because somebody
 * asked not to be messaged, and deleting one on an erasure request would resume
 * messaging the very person who opted out — the opposite of what they wanted.
 * A suppression list is a legitimate reason to retain the minimum needed to
 * honour an objection, and that is the only thing held back.
 */
final class PersonalData {

	/**
	 * Export group id.
	 */
	private const GROUP = 'texttango';

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) );
		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) );
		add_action( 'admin_init', array( $this, 'add_privacy_policy_content' ) );
	}

	/**
	 * Declare the exporter.
	 *
	 * @param array<string, mixed> $exporters Registered exporters.
	 * @return array<string, mixed>
	 */
	public function register_exporter( $exporters ) {
		$exporters[ self::GROUP ] = array(
			'exporter_friendly_name' => __( 'TextTango SMS', 'texttango' ),
			'callback'               => array( $this, 'export' ),
		);

		return $exporters;
	}

	/**
	 * Declare the eraser.
	 *
	 * @param array<string, mixed> $erasers Registered erasers.
	 * @return array<string, mixed>
	 */
	public function register_eraser( $erasers ) {
		$erasers[ self::GROUP ] = array(
			'eraser_friendly_name' => __( 'TextTango SMS', 'texttango' ),
			'callback'             => array( $this, 'erase' ),
		);

		return $erasers;
	}

	/**
	 * Everything held about a person.
	 *
	 * @param string $email Email address of the data subject.
	 * @param int    $page  Page number, one-based.
	 * @return array{data: array<int, mixed>, done: bool}
	 */
	public function export( $email, $page = 1 ) {
		// Everything fits one page; the parameter is part of WordPress's contract.
		unset( $page );

		$user = get_user_by( 'email', (string) $email );
		$data = array();

		if ( $user ) {
			$data[] = array(
				'group_id'    => self::GROUP,
				'group_label' => __( 'TextTango SMS', 'texttango' ),
				'item_id'     => 'texttango-profile',
				'data'        => $this->profile_items( $user->ID ),
			);
		}

		foreach ( $this->messages_for( $user ? $user->ID : 0, (string) $email ) as $index => $row ) {
			$data[] = array(
				'group_id'    => self::GROUP . '-messages',
				'group_label' => __( 'TextTango messages', 'texttango' ),
				'item_id'     => 'texttango-message-' . (int) $row['id'],
				'data'        => array(
					array(
						'name'  => __( 'Sent', 'texttango' ),
						'value' => (string) $row['created_at'],
					),
					array(
						'name'  => __( 'To', 'texttango' ),
						'value' => (string) $row['recipient'],
					),
					array(
						'name'  => __( 'Reason', 'texttango' ),
						'value' => (string) $row['event'],
					),
					array(
						'name'  => __( 'Status', 'texttango' ),
						'value' => (string) $row['status'],
					),
					array(
						'name'  => __( 'Message', 'texttango' ),
						'value' => (string) $row['body'],
					),
				),
			);

			unset( $index );
		}

		/*
		 * Discount codes issued to this person by a personal-code campaign.
		 *
		 * They live in their own table because the platform never saw them — a
		 * personal campaign is fanned out from here — so this site is the only
		 * place holding the number and the code together, and therefore the only
		 * place that can answer for them.
		 */
		foreach ( PersonalCampaign::for_numbers( $this->numbers_for( $user ? $user->ID : 0, (string) $email ) ) as $row ) {
			$code = (string) ( $row['coupon'] ?? '' );

			$data[] = array(
				'group_id'    => self::GROUP . '-coupons',
				'group_label' => __( 'TextTango discount codes', 'texttango' ),
				'item_id'     => 'texttango-coupon-' . (int) $row['id'],
				'data'        => array(
					array(
						'name'  => __( 'Sent', 'texttango' ),
						'value' => (string) ( $row['sent_at'] ?? $row['created_at'] ?? '' ),
					),
					array(
						'name'  => __( 'To', 'texttango' ),
						'value' => (string) $row['recipient'],
					),
					array(
						'name'  => __( 'Code', 'texttango' ),
						'value' => $code,
					),
					array(
						'name'  => __( 'Used', 'texttango' ),
						'value' => PersonalCampaign::redeemed( $code )
							? __( 'Yes', 'texttango' )
							: __( 'No', 'texttango' ),
					),
				),
			);
		}

		return array(
			'data' => $data,
			'done' => true,
		);
	}

	/**
	 * Remove what can be removed.
	 *
	 * @param string $email Email address of the data subject.
	 * @param int    $page  Page number, one-based.
	 * @return array{items_removed: bool, items_retained: bool, messages: string[], done: bool}
	 */
	public function erase( $email, $page = 1 ) {
		global $wpdb;

		unset( $page );

		$user     = get_user_by( 'email', (string) $email );
		$removed  = false;
		$retained = false;
		$messages = array();
		$user_id  = $user ? $user->ID : 0;
		$phone    = $user ? UserRepository::phone_for( $user->ID ) : '';

		/*
		 * **Resolved before anything is deleted, and that ordering is the whole
		 * correctness of this method.**
		 *
		 * Their number is how rows are found: message rows carry a recipient,
		 * campaign codes are keyed by one, and the consent and opt-out lists are
		 * keyed by nothing else. But the number itself lives in user meta that
		 * this erasure removes — so resolving afterwards finds nothing, and the
		 * rows it was meant to remove quietly survive the request.
		 *
		 * That is not hypothetical: the meta deletion used to run first, so a
		 * message row matched only by number rather than by account was never
		 * erased at all.
		 */
		$rows    = $this->messages_for( $user_id, (string) $email );
		$numbers = $this->numbers_for( $user_id, (string) $email );

		if ( $user ) {
			foreach (
				array(
					UserRepository::META_PHONE,
					UserRepository::META_VERIFIED_AT,
					UserRepository::META_2FA,
					Consent::META_OPTIN,
					Consent::META_OPTIN_AT,
					Consent::META_OPTIN_SOURCE,
					ContactRepository::META_UUID,
					ContactRepository::META_SYNCED_AT,
				) as $meta_key
			) {
				if ( '' !== (string) get_user_meta( $user->ID, $meta_key, true ) ) {
					delete_user_meta( $user->ID, $meta_key );
					$removed = true;
				}
			}
		}

		$table = Schema::table_name();

		foreach ( $rows as $row ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
			$wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) );

			$removed = true;
		}

		/*
		 * The codes issued to this person, and the link from each coupon back to
		 * their number. The coupon itself is kept — see erase_for() for why a
		 * redeemed code attached to a real order is not ours to delete.
		 */
		if ( PersonalCampaign::erase_for( $numbers ) > 0 ) {
			$removed = true;
		}

		/*
		 * Consent is deleted, and that is deliberately the opposite of what
		 * happens to a suppression just below. A suppression is a request to be
		 * left alone and must outlive an erasure or the request is undone. A
		 * consent record is *permission*, and permission must not outlive
		 * somebody asking to be forgotten — keeping it would mean carrying on
		 * messaging the person who asked.
		 */
		if ( '' !== $phone && ConsentList::contains( $phone ) ) {
			ConsentList::remove( $phone );

			$removed = true;
		}

		/*
		 * The one thing kept. A suppression exists because this person asked not
		 * to be messaged; deleting it would start messaging them again, which is
		 * the opposite of honouring the request that created it.
		 */
		if ( '' !== $phone && Suppressions::contains( $phone ) ) {
			$retained   = true;
			$messages[] = __( 'TextTango kept this number on its opt-out list, so the request not to be messaged continues to be honoured. Nothing else about the number was kept.', 'texttango' );
		}

		return array(
			'items_removed'  => $removed,
			'items_retained' => $retained,
			'messages'       => $messages,
			'done'           => true,
		);
	}

	/**
	 * The profile-level facts held about a user.
	 *
	 * @param int $user_id User id.
	 * @return array<int, array{name: string, value: string}>
	 */
	private function profile_items( int $user_id ): array {
		$phone  = UserRepository::phone_for( $user_id );
		$record = Consent::record_for( $user_id );

		$items = array();

		if ( '' !== $phone ) {
			$items[] = array(
				'name'  => __( 'Sign-in phone number', 'texttango' ),
				'value' => $phone,
			);
			$items[] = array(
				'name'  => __( 'Number verified', 'texttango' ),
				'value' => (string) get_user_meta( $user_id, UserRepository::META_VERIFIED_AT, true ),
			);
		}

		$items[] = array(
			'name'  => __( 'Agreed to marketing messages', 'texttango' ),
			'value' => Consent::state_label( (string) $record['state'] ),
		);

		if ( '' !== $record['at'] ) {
			$items[] = array(
				'name'  => __( 'Preference recorded', 'texttango' ),
				'value' => $record['at'],
			);
			$items[] = array(
				'name'  => __( 'Recorded from', 'texttango' ),
				'value' => $record['source'],
			);
		}

		$uuid = ContactRepository::uuid_for_user( $user_id );

		if ( '' !== $uuid ) {
			$items[] = array(
				'name'  => __( 'TextTango contact id', 'texttango' ),
				'value' => $uuid,
			);
		}

		if ( '' !== $phone && ConsentList::contains( $phone ) ) {
			$items[] = array(
				'name'  => __( 'Agreed to marketing messages', 'texttango' ),
				// Said in full, because it is the least obvious record this
				// plugin holds: consent recorded against the number alone,
				// brought over from somewhere else, with no account behind it.
				'value' => __( 'Yes — recorded against this number, imported from another system', 'texttango' ),
			);
		}
		if ( '' !== $phone && Suppressions::contains( $phone ) ) {
			$items[] = array(
				'name'  => __( 'Opted out of messages', 'texttango' ),
				'value' => __( 'Yes', 'texttango' ),
			);
		}

		return $items;
	}

	/**
	 * Message-log rows belonging to a person.
	 *
	 * Matched by user id and by the numbers on their orders, since a guest
	 * checkout leaves messages with no user attached to them.
	 *
	 * @param int    $user_id User id, or 0.
	 * @param string $email   Email address.
	 * @return array<int, array<string, mixed>>
	 */
	private function messages_for( int $user_id, string $email ): array {
		global $wpdb;

		$numbers = $this->numbers_for( $user_id, $email );

		if ( $user_id <= 0 && empty( $numbers ) ) {
			return array();
		}

		$table = Schema::table_name();
		$found = array();

		/*
		 * Deliberately several fixed queries rather than one with an assembled
		 * WHERE clause. A person may be matched by their account or by any number
		 * on their orders, and building that as dynamic SQL puts the shape of the
		 * query beyond what either the reader or the linter can check. The counts
		 * here are tiny, so the extra round trips cost nothing worth having.
		 */
		if ( $user_id > 0 ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name comes from $wpdb->prefix.
			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE wp_user_id = %d", $user_id ), ARRAY_A );

			foreach ( (array) $rows as $row ) {
				$found[ (int) $row['id'] ] = $row;
			}
		}

		foreach ( $numbers as $number ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table; the name comes from $wpdb->prefix.
			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE recipient = %s", $number ), ARRAY_A );

			foreach ( (array) $rows as $row ) {
				$found[ (int) $row['id'] ] = $row;
			}
		}

		krsort( $found );

		return array_values( $found );
	}

	/**
	 * Every number known to belong to a person.
	 *
	 * @param int    $user_id User id, or 0.
	 * @param string $email   Email address.
	 * @return string[]
	 */
	private function numbers_for( int $user_id, string $email ): array {
		$numbers = array();

		if ( $user_id > 0 ) {
			$phone = UserRepository::phone_for( $user_id );

			if ( '' !== $phone ) {
				$numbers[] = $phone;
			}
		}

		if ( function_exists( 'wc_get_orders' ) && '' !== $email ) {
			$orders = wc_get_orders(
				array(
					'billing_email' => $email,
					'limit'         => 100,
					'return'        => 'objects',
				)
			);

			foreach ( (array) $orders as $order ) {
				if ( ! $order instanceof \WC_Order ) {
					continue;
				}

				$normalized = PhoneNumber::normalize( (string) $order->get_billing_phone(), $order->get_billing_country() );

				if ( ! is_wp_error( $normalized ) ) {
					$numbers[] = $normalized;
				}
			}
		}

		return array_values( array_unique( $numbers ) );
	}

	/**
	 * Suggest privacy-policy wording, as WordPress asks plugins to.
	 */
	public function add_privacy_policy_content(): void {
		if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
			return;
		}

		$content = '<p>' . __( 'When you give us a phone number we store it, along with a record of what you agreed to receive, when, and where you agreed it. We keep a log of the text messages we send you, including their content and delivery status.', 'texttango' ) . '</p>'
			. '<p>' . __( 'Messages are sent through TextTango, so your number and name are shared with them for that purpose. If you ask us to stop messaging you, we keep your number on an opt-out list — that is the only way we can be sure not to message you again.', 'texttango' ) . '</p>';

		wp_add_privacy_policy_content( __( 'TextTango SMS', 'texttango' ), wp_kses_post( $content ) );
	}
}
