<?php
/**
 * Message-log retention.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Privacy;

use TextTango\Audit\AuditLog;
use TextTango\Analytics\Stats;
use TextTango\Install\Schema;
use TextTango\Marketing\PersonalCampaign;
use TextTango\Sending\Queue;
use TextTango\Support\Clock;
use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Purges old delivery-log rows (spec §22).
 *
 * The log holds a phone number and the text of every message sent to it, which
 * is personal data with a shelf life. Keeping it forever is a choice a merchant
 * should make deliberately rather than inherit — but it is also the record of
 * what was sent and whether it arrived, so the default is to keep it and let a
 * limit be set.
 */
final class Retention {

	/**
	 * Action Scheduler hook for the purge.
	 */
	public const HOOK = 'texttango_purge_log';

	/**
	 * How often the purge runs, in seconds.
	 */
	public const INTERVAL = DAY_IN_SECONDS;

	/**
	 * Rows removed per run, so a long-neglected log cannot stall the queue.
	 */
	public const BATCH = 500;

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

	/**
	 * Days a row is kept. Zero means forever.
	 */
	public static function days(): int {
		return max( 0, (int) Settings::get( 'log_retention_days', 0 ) );
	}

	/**
	 * Days an audit entry is kept. Zero means forever.
	 *
	 * Separate from the message log on purpose, and defaulting to keep. The two
	 * hold different things for different reasons: a message body is personal
	 * data with a shelf life, while the audit trail is accountability, and the
	 * question it answers — who changed this — is usually asked long after
	 * anyone would think to keep the evidence.
	 */
	public static function audit_days(): int {
		return max( 0, (int) Settings::get( 'audit_retention_days', 0 ) );
	}

	/**
	 * Make sure the daily purge 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 );
	}

	/**
	 * Delete rows older than the limit.
	 *
	 * @return int How many were removed.
	 */
	public static function run(): int {
		global $wpdb;

		$removed = self::purge_audit();
		$days    = self::days();

		if ( $days <= 0 ) {
			return $removed;
		}

		$cutoff = gmdate( 'Y-m-d H:i:s', Clock::now() - ( $days * DAY_IN_SECONDS ) );
		$table  = Schema::table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table.
		$deleted = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Values are placeholders; only the table name is interpolated, from $wpdb->prefix.
				"DELETE FROM {$table} WHERE created_at < %s LIMIT %d",
				$cutoff,
				self::BATCH
			)
		);

		/*
		 * The personal-campaign recipients, under the same window.
		 *
		 * Those rows hold a phone number and the code issued to whoever answers
		 * it, and nothing pruned them — so a merchant who asked the plugin to
		 * stop keeping numbers after ninety days kept these ones forever. A
		 * retention setting honoured on one table and ignored on another is not a
		 * retention setting.
		 */
		$recipients = PersonalCampaign::prune( $cutoff, self::BATCH );

		// Every figure the analytics panel shows counts rows that have just gone,
		// so the cache cannot be allowed to outlive them.
		Stats::flush();

		return $removed + $recipients + ( false === $deleted ? 0 : (int) $deleted );
	}

	/**
	 * Trim the audit trail to its own limit.
	 *
	 * @return int How many were removed.
	 */
	public static function purge_audit(): int {
		$days = self::audit_days();

		if ( $days <= 0 ) {
			return 0;
		}

		return AuditLog::purge_before(
			gmdate( 'Y-m-d H:i:s', Clock::now() - ( $days * DAY_IN_SECONDS ) ),
			self::BATCH
		);
	}
}
