<?php
/**
 * Feeds the audit trail from events elsewhere in the plugin.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Audit;

use TextTango\Api\PhoneNumber;
use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Turns things that happen into entries somebody can read later (spec §19).
 *
 * Kept apart from the code being audited: every source here already fires an
 * action or a filter for its own reasons, so recording is a listener rather
 * than a line threaded through a dozen call sites. That also means switching
 * the audit trail off would be one `remove_action`, and forgetting to record
 * something new shows up as a missing listener rather than as a silent gap in
 * the middle of unrelated code.
 */
final class Recorder {

	/**
	 * Settings whose value is a judgement rather than a number, and so worth
	 * spelling out when it changes.
	 *
	 * @var string[]
	 */
	private const NOTABLE = array(
		'spend_cap_daily',
		'spend_cap_monthly',
		'spend_cap_recipient',
		'balance_warn_below',
		'balance_pause_below',
		'sandbox_mode',
		'sandbox_behavior',
		'api_base_url',
		'sender_id',
		'consent_double_optin',
		'contacts_sync_enabled',
		'log_retention_days',
		'keep_data_on_uninstall',
		'otp_login_enabled',
		'otp_2fa_enabled',
		'quiet_hours_enabled',
		// Both hold lists. Which events are switched on, and which roles must use
		// two-factor, are exactly the changes somebody asks about later.
		'notifications',
		'otp_2fa_roles',
	);

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'texttango_settings_updated', array( $this, 'on_settings' ), 10, 3 );
		add_action( 'texttango_secret_changed', array( $this, 'on_secret' ), 10, 2 );
		add_action( 'texttango_recipient_suppressed', array( $this, 'on_suppressed' ), 10, 3 );
		add_action( 'texttango_consent_recorded', array( $this, 'on_consent' ), 10, 4 );
		add_action( 'texttango_webhook_secret_rotated', array( $this, 'on_rotation' ) );
	}

	/**
	 * A settings change.
	 *
	 * @param string[]             $changed Keys that differ.
	 * @param array<string, mixed> $before  Settings as they were.
	 * @param array<string, mixed> $after   Settings as they now are.
	 */
	public function on_settings( $changed, $before, $after ): void {
		$changed = (array) $changed;
		$notable = array_values( array_intersect( $changed, self::NOTABLE ) );

		$detail = array( 'changed' => implode( ', ', $changed ) );

		foreach ( $notable as $key ) {
			$detail[ $key ] = self::render( $before[ $key ] ?? null ) . ' → ' . self::render( $after[ $key ] ?? null );
		}

		AuditLog::record(
			AuditLog::ACTION_SETTINGS,
			sprintf(
				/* translators: %d: number of settings changed. */
				_n( '%d setting changed', '%d settings changed', count( $changed ), 'texttango' ),
				count( $changed )
			),
			$detail
		);
	}

	/**
	 * A credential replaced or removed.
	 *
	 * @param string $option  Which secret.
	 * @param bool   $present Whether one is now stored.
	 */
	public function on_secret( $option, $present ): void {
		$names = array(
			Settings::OPTION_API_KEY        => __( 'API token', 'texttango' ),
			Settings::OPTION_WEBHOOK_SECRET => __( 'Webhook signing secret', 'texttango' ),
		);

		AuditLog::record(
			AuditLog::ACTION_SECRET,
			sprintf(
				$present
					/* translators: %s: name of the credential. */
					? __( '%s replaced', 'texttango' )
					/* translators: %s: name of the credential. */
					: __( '%s removed', 'texttango' ),
				$names[ (string) $option ] ?? (string) $option
			)
		);
	}

	/**
	 * A number added to the opt-out list.
	 *
	 * @param string $recipient E.164 recipient.
	 * @param string $reason    Why.
	 * @param string $source    Where it was learned.
	 */
	public function on_suppressed( $recipient, $reason, $source ): void {
		AuditLog::record(
			AuditLog::ACTION_OPT_OUT,
			// Masked. The trail says a number opted out; it does not need to be a
			// second copy of the number, readable by everyone who can read this.
			PhoneNumber::mask( (string) $recipient ),
			array(
				'reason' => (string) $reason,
				'source' => (string) $source,
			)
		);
	}

	/**
	 * A marketing preference changed by somebody other than its owner.
	 *
	 * Only admin-made changes are recorded. A customer ticking their own box at
	 * checkout is already evidenced by the consent record itself, with its own
	 * timestamp and source; writing it here too would bury the entries that
	 * actually need explaining under thousands that do not.
	 *
	 * @param int    $user_id User whose preference changed.
	 * @param bool   $granted Whether they may now be marketed to.
	 * @param string $source  Where it was captured.
	 * @param string $state   Full state.
	 */
	public function on_consent( $user_id, $granted, $source, $state = '' ): void {
		if ( 'admin' !== (string) $source ) {
			return;
		}

		$user = get_userdata( (int) $user_id );

		AuditLog::record(
			AuditLog::ACTION_CONSENT,
			sprintf(
				/* translators: %s: the customer's display name or login. */
				__( 'Marketing preference changed for %s', 'texttango' ),
				$user ? $user->user_login : '#' . (int) $user_id
			),
			array( 'state' => '' !== (string) $state ? (string) $state : ( $granted ? 'granted' : 'denied' ) )
		);
	}

	/**
	 * The webhook signing secret was rotated from WordPress.
	 */
	public function on_rotation(): void {
		AuditLog::record(
			AuditLog::ACTION_WEBHOOK,
			__( 'Webhook signing secret rotated', 'texttango' )
		);
	}

	/**
	 * Record a message somebody sent by hand.
	 *
	 * Called directly rather than hooked, because "by hand" is a fact about the
	 * caller that no event carries — an order notification and a test send reach
	 * the sender looking identical.
	 *
	 * @param string $recipient E.164 recipient.
	 * @param string $how       Where it was sent from, e.g. 'settings screen'.
	 */
	public static function manual_send( string $recipient, string $how ): void {
		AuditLog::record(
			AuditLog::ACTION_MANUAL_SEND,
			PhoneNumber::mask( $recipient ),
			array( 'from' => $how )
		);
	}

	/**
	 * A setting value as one short readable string.
	 *
	 * @param mixed $value Stored value.
	 */
	private static function render( $value ): string {
		if ( is_bool( $value ) ) {
			return $value ? 'on' : 'off';
		}

		if ( is_array( $value ) ) {
			/* translators: %d: number of items in a setting that holds a list. */
			return sprintf( _n( '%d item', '%d items', count( $value ), 'texttango' ), count( $value ) );
		}

		if ( null === $value || '' === $value ) {
			return '—';
		}

		return substr( (string) $value, 0, 60 );
	}
}
