<?php
/**
 * Trust score calculator.
 *
 * @package TrustLens
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Score calculator class.
 *
 * @since 1.0.0
 */
class TrustLens_Score_Calculator {

	/**
	 * Base score for new customers.
	 *
	 * @var int
	 */
	private int $base_score = 50;

	/**
	 * Collected signals.
	 *
	 * @var array
	 */
	private array $signals = array();

	/**
	 * Calculate trust score for customer.
	 *
	 * @since 1.0.0
	 * @param string $email_hash Customer email hash.
	 * @return array|null Result array or null on failure.
	 */
	public function calculate( string $email_hash ): ?array {
		$customer = wstl_get_customer( $email_hash );

		if ( ! $customer ) {
			return null;
		}

		// Check allowlist - always trusted.
		if ( $customer->is_allowlisted ) {
			return array(
				'score'   => 100,
				'segment' => 'vip',
				'signals' => array(),
			);
		}

		// Check minimum orders threshold. A Pro feature holding confident EXTERNAL
		// intelligence about this customer (e.g. a Store Trust Network peer flag) can
		// force full scoring even on thin local data via the filter below.
		$min_orders = (int) get_option( 'trustlens_min_orders', 3 );
		$thin_data  = $customer->total_orders < $min_orders;

		/**
		 * Filter whether to skip module scoring for a below-min_orders customer.
		 *
		 * Default true when local order count is under the threshold. Return false
		 * to force full module scoring despite thin local data.
		 *
		 * @since 1.3.5
		 * @param bool   $thin_data Whether to skip module scoring (insufficient local data).
		 * @param object $customer  Customer row.
		 */
		$thin_data = (bool) apply_filters( 'trustlens/skip_thin_data_scoring', $thin_data, $customer );

		if ( $thin_data ) {
			return array(
				'score'   => $this->base_score,
				'segment' => 'normal',
				'signals' => array(
					array(
						'module' => 'system',
						'score'  => 0,
						'reason' => sprintf(
							/* translators: %1$d: current order count, %2$d: minimum orders required */
							__( 'Insufficient data (%1$d/%2$d orders)', 'trustlens' ),
							$customer->total_orders,
							$min_orders
						),
					),
				),
			);
		}

		// Reset signals.
		$this->signals = array();

		// Collect signals from all active modules.
		$modules = wstl_get_active_modules();

		foreach ( $modules as $module ) {
			// Pass the customer row fetched above so each module reuses one lookup
			// instead of re-querying the same record inside its get_signal().
			$signal = $module->get_signal( $email_hash, $customer );

			if ( ! empty( $signal['score'] ) || ! empty( $signal['reason'] ) ) {
				$this->signals[] = array(
					'module' => $module->get_id(),
					'score'  => $signal['score'] ?? 0,
					'reason' => $signal['reason'] ?? '',
				);
			}
		}

		// Add account age bonus.
		$age_bonus = $this->get_account_age_bonus( $customer );
		if ( $age_bonus > 0 ) {
			$this->signals[] = array(
				'module' => 'account_age',
				'score'  => $age_bonus,
				'reason' => $this->get_age_bonus_reason( $customer ),
			);
		}

		/**
		 * Filter signals before final score calculation.
		 *
		 * @since 1.1.0
		 * @param array  $signals    Current signals.
		 * @param string $email_hash Customer email hash.
		 */
		$this->signals = apply_filters( 'trustlens/score_signals', $this->signals, $email_hash );

		// Calculate total adjustment.
		$total_adjustment = 0;
		foreach ( $this->signals as $signal ) {
			$total_adjustment += $signal['score'];
		}

		// Calculate final score.
		$final_score = $this->base_score + $total_adjustment;

		// Clamp to 0-100.
		$final_score = max( 0, min( 100, $final_score ) );

		// Apply filter for customization.
		$final_score = apply_filters( 'trustlens/trust_score', $final_score, $email_hash, $this->signals );

		// Determine segment.
		$segment = wstl_get_segment_from_score( $final_score );

		// Log signals for debugging.
		$this->log_signals( $email_hash, $final_score );

		return array(
			'score'   => (int) $final_score,
			'segment' => $segment,
			'signals' => $this->signals,
		);
	}

	/**
	 * Get account age bonus.
	 *
	 * @since 1.0.0
	 * @param object $customer Customer object.
	 * @return int Bonus score.
	 */
	private function get_account_age_bonus( object $customer ): int {
		if ( empty( $customer->first_order_date ) ) {
			return 0;
		}

		$days = wstl_days_between( $customer->first_order_date, current_time( 'timestamp' ) );

		if ( $days >= 365 ) {
			return 15;
		}
		if ( $days >= 180 ) {
			return 10;
		}
		if ( $days >= 90 ) {
			return 5;
		}

		return 0;
	}

	/**
	 * Get age bonus reason.
	 *
	 * @since 1.0.0
	 * @param object $customer Customer object.
	 * @return string Reason text.
	 */
	private function get_age_bonus_reason( object $customer ): string {
		$days = wstl_days_between( $customer->first_order_date, current_time( 'timestamp' ) );

		if ( $days >= 365 ) {
			return __( 'Long-term customer (1+ year)', 'trustlens' );
		}
		if ( $days >= 180 ) {
			return __( 'Established customer (6+ months)', 'trustlens' );
		}
		if ( $days >= 90 ) {
			return __( 'Regular customer (3+ months)', 'trustlens' );
		}

		return '';
	}

	/**
	 * Log signals to database.
	 *
	 * @since 1.0.0
	 * @param string $email_hash  Customer email hash.
	 * @param int    $final_score Final score.
	 */
	private function log_signals( string $email_hash, int $final_score ): void {
		global $wpdb;

		$table = $wpdb->prefix . 'trustlens_signals';
		$now   = current_time( 'mysql' );

		// Replace this customer's CURRENT risk signals — those describe present
		// state, so the old rows are stale and must go. Crucially, this delete
		// must NOT touch the 'score_update' rows: those form the append-only
		// daily time series that TrustLens_Analytics::get_score_trends() reads to
		// draw the Trust Score Trends chart. A blanket DELETE (the prior code)
		// wiped each customer's history on every recalc, leaving at most one
		// snapshot per customer — so the chart could never show a real trend.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE email_hash = %s AND module_id != 'score_update'",
				$email_hash
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		// Insert new signals.
		foreach ( $this->signals as $signal ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix, safe.
			$wpdb->insert(
				$table,
				array(
					'email_hash'    => $email_hash,
					'module_id'     => $signal['module'],
					'signal_score'  => $signal['score'],
					'signal_reason' => $signal['reason'],
					'created_at'    => $now,
				),
				array( '%s', '%s', '%d', '%s', '%s' )
			);
		}

		// Store final score as a 'score_update' snapshot so the dashboard Trust
		// Score Trends chart plots actual 0-100 scores, not module deltas. Keep at
		// most ONE snapshot per customer per calendar day: clear today's row first
		// so repeated same-day recalcs overwrite rather than stack, while earlier
		// days are preserved as history. Day is measured on the WP-local clock to
		// match created_at (current_time( 'mysql' )) and the chart's DATE() bucket.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE email_hash = %s AND module_id = 'score_update' AND DATE( created_at ) = %s",
				$email_hash,
				current_time( 'Y-m-d' )
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix, safe.
		$wpdb->insert(
			$table,
			array(
				'email_hash'    => $email_hash,
				'module_id'     => 'score_update',
				'signal_score'  => $final_score,
				'signal_reason' => __( 'Store snapshot', 'trustlens' ),
				'created_at'    => $now,
			),
			array( '%s', '%s', '%d', '%s', '%s' )
		);
	}

	/**
	 * Prune old 'score_update' history rows so the trend series stays bounded.
	 *
	 * The daily snapshots in trustlens_signals accumulate one row per customer
	 * per day. The Trust Score Trends chart never looks back more than a few
	 * months, so anything past the retention horizon is dead weight. Runs from
	 * the daily cleanup cron. Only 'score_update' rows are touched — live risk
	 * signals are rewritten on each recalc and are not time-series data.
	 *
	 * @since 1.3.8
	 * @param int $keep_days Days of snapshot history to retain. Default 400.
	 * @return int Rows removed.
	 */
	public static function prune_score_history( int $keep_days = 400 ): int {
		global $wpdb;

		$keep_days = max( 1, $keep_days );
		$table     = $wpdb->prefix . 'trustlens_signals';
		// created_at is written with current_time( 'mysql' ) (WP-local), so anchor
		// the cutoff to the same local clock rather than SQL NOW()/UTC_TIMESTAMP().
		$cutoff = gmdate( 'Y-m-d H:i:s', (int) current_time( 'timestamp' ) - $keep_days * DAY_IN_SECONDS );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe; scheduled retention sweep.
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE module_id = 'score_update' AND created_at < %s",
				$cutoff
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		return (int) $wpdb->rows_affected;
	}

	/**
	 * Get risk signals for a customer.
	 *
	 * Excludes the internal 'score_update' snapshot row written by
	 * log_signals() — that row stores the final 0-100 score purely so the
	 * Trust Score Trends chart can query it (see TrustLens_Analytics), and is
	 * not a risk signal, so it must not appear in signal listings such as the
	 * customer detail view or dispute report.
	 *
	 * @since 1.0.0
	 * @param string $email_hash Customer email hash.
	 * @return array Signals array.
	 */
	public static function get_signals( string $email_hash ): array {
		global $wpdb;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		return $wpdb->get_results( $wpdb->prepare(
			"SELECT * FROM {$wpdb->prefix}trustlens_signals
			 WHERE email_hash = %s AND module_id != 'score_update'
			 ORDER BY signal_score ASC",
			$email_hash
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	/**
	 * Single instance.
	 *
	 * @var TrustLens_Score_Calculator|null
	 */
	private static ?TrustLens_Score_Calculator $instance = null;

	/**
	 * Get instance.
	 *
	 * @since 1.1.0
	 * @return TrustLens_Score_Calculator
	 */
	public static function instance(): TrustLens_Score_Calculator {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Recalculate and save score for a customer.
	 *
	 * @since 1.1.0
	 * @param string $email_hash Customer email hash.
	 * @return array|null Result array or null on failure.
	 */
	public function recalculate_score( string $email_hash ): ?array {
		$result = $this->calculate( $email_hash );

		if ( $result ) {
			$existing    = wstl_get_customer( $email_hash );
			$old_segment = $existing ? (string) $existing->segment : '';

			wstl_update_customer( $email_hash, array(
				'trust_score'      => $result['score'],
				'segment'          => $result['segment'],
				'score_updated_at' => current_time( 'mysql' ),
			) );

			do_action( 'trustlens/score_updated', $email_hash, $result['score'], $result['segment'] );

			if ( $old_segment !== (string) $result['segment'] ) {
				do_action( 'trustlens/segment_changed', $email_hash, (string) $result['segment'], $old_segment, (int) $result['score'] );
				// A segment change moves the customer between dashboard segment
				// buckets and in/out of the high-risk list — refresh those caches.
				// Fires from the shared recalc path, so this covers every module.
				if ( function_exists( 'wstl_invalidate_customer_caches' ) ) {
					wstl_invalidate_customer_caches();
				}
			}
		}

		return $result;
	}
}
