<?php
/**
 * Chargeback Monitor.
 *
 * Computes per-brand and blended chargeback ratios against card-network
 * monitoring-program thresholds (Visa VDMP, Visa VFMP, Mastercard ECP,
 * Amex, Discover) and dispatches email alerts when any brand crosses a
 * configurable warning percentage of its threshold.
 *
 * @package TrustLens
 * @since   1.2.1
 */

defined( 'ABSPATH' ) || exit;

/**
 * Chargeback Monitor class.
 *
 * Pure computation + alert dispatch — registers no hooks itself; the daily
 * alert cron is wired in TrustLens::init_hooks() only when Pro is active.
 * Data-capture hooks live in TrustLens_Card_Brand_Detector.
 *
 * Free surface: get_ratios(), get_threshold_status(), get_nearest_threshold(),
 * compute_ratio(). Pro surface: get_trend(), check_and_alert(), alerts_to_send().
 *
 * @since 1.2.1
 */
class TrustLens_Chargeback_Monitor {

	const CRON_HOOK       = 'trustlens/chargeback_alert_check';
	const OPTION_ALERTS   = 'trustlens_chargeback_alerts_sent';
	const OPTION_WARN_PCT = 'trustlens_chargeback_alert_threshold_pct';
	const RATIOS_CACHE    = 'trustlens_chargeback_ratios';
	const RATIOS_TTL      = 900; // 15 minutes — see spec §Risks.
	const TREND_CACHE     = 'trustlens_chargeback_trend';
	const TREND_TTL       = 3600; // 1 hour — trend data is historical and changes slowly.

	const THRESHOLDS = array(
		'visa_vdmp' => array( 'brand' => 'visa',       'ratio' => 0.009,  'label' => 'Visa VDMP' ),
		'visa_vfmp' => array( 'brand' => 'visa',       'ratio' => 0.0065, 'label' => 'Visa VFMP' ),
		'mc_ecp'    => array( 'brand' => 'mastercard', 'ratio' => 0.015,  'label' => 'Mastercard ECP' ),
		'amex'      => array( 'brand' => 'amex',       'ratio' => 0.01,   'label' => 'Amex Excessive Disputes' ),
		'discover'  => array( 'brand' => 'discover',   'ratio' => 0.01,   'label' => 'Discover Excessive Disputes' ),
	);

	/**
	 * Pure ratio math.
	 *
	 * @since 1.2.1
	 */
	public static function compute_ratio( int $orders, int $disputes ): float {
		if ( $orders <= 0 ) {
			return 0.0;
		}
		return (float) $disputes / (float) $orders;
	}

	/**
	 * Map ratios to per-threshold status.
	 *
	 * @since 1.2.1
	 * @param array $ratios   Subset of get_ratios() output (needs brands[]).
	 * @param int   $warn_pct Warning percent of threshold (50-100).
	 * @return array threshold_key => { status, ratio, orders, disputes, brand, threshold, label, warn_line }
	 */
	public static function get_threshold_status( array $ratios, int $warn_pct ): array {
		$warn_pct = max( 50, min( 100, $warn_pct ) );
		$by_brand = array();
		foreach ( $ratios['brands'] ?? array() as $row ) {
			$by_brand[ $row['brand'] ] = $row;
		}

		$out = array();
		foreach ( self::THRESHOLDS as $key => $cfg ) {
			$brand     = $cfg['brand'];
			$threshold = (float) $cfg['ratio'];
			$warn_line = $threshold * ( $warn_pct / 100 );

			if ( ! isset( $by_brand[ $brand ] ) || ( (int) ( $by_brand[ $brand ]['orders'] ?? 0 ) ) === 0 ) {
				$out[ $key ] = array(
					'status'    => 'no_data',
					'ratio'     => 0.0,
					'orders'    => 0,
					'disputes'  => 0,
					'brand'     => $brand,
					'threshold' => $threshold,
					'label'     => $cfg['label'],
					'warn_line' => $warn_line,
				);
				continue;
			}

			$row    = $by_brand[ $brand ];
			$ratio  = (float) ( $row['ratio'] ?? 0 );
			$status = 'healthy';
			if ( $ratio >= $threshold ) {
				$status = 'danger';
			} elseif ( $ratio >= $warn_line ) {
				$status = 'warning';
			}

			$out[ $key ] = array(
				'status'    => $status,
				'ratio'     => $ratio,
				'orders'    => (int) ( $row['orders'] ?? 0 ),
				'disputes'  => (int) ( $row['disputes'] ?? 0 ),
				'brand'     => $brand,
				'threshold' => $threshold,
				'label'     => $cfg['label'],
				'warn_line' => $warn_line,
			);
		}
		return $out;
	}

	/**
	 * Pick the threshold the store is closest to crossing, evaluated against the
	 * blended ratio. Conservative — applies each brand's network threshold to
	 * the blended count even though networks compute per-brand. Used by the Free
	 * dashboard card to create a meaningful proximity signal without exposing
	 * the per-brand breakdown, which is Pro.
	 *
	 * Returns null when there are no card orders yet (nothing to compare to).
	 *
	 * @since 1.2.1
	 * @param array $window_ratios One of the top-level windows from get_ratios()
	 *                             (needs blended.orders, blended.disputes, blended.ratio).
	 * @return array|null { key, label, brand, threshold, ratio, orders, disputes,
	 *                     disputes_to_hit, status } or null if no data.
	 */
	public static function get_nearest_threshold( array $window_ratios ): ?array {
		$blended = $window_ratios['blended'] ?? array();
		$orders  = (int) ( $blended['orders'] ?? 0 );
		if ( $orders <= 0 ) {
			return null;
		}
		$disputes = (int) ( $blended['disputes'] ?? 0 );
		$ratio    = (float) ( $blended['ratio'] ?? 0 );

		$best = null;
		foreach ( self::THRESHOLDS as $key => $cfg ) {
			$threshold   = (float) $cfg['ratio'];
			$target      = (int) ceil( $threshold * $orders );
			$to_hit      = max( 0, $target - $disputes );
			$status      = $ratio >= $threshold ? 'danger' : 'warning';

			$candidate = array(
				'key'             => $key,
				'label'           => $cfg['label'],
				'brand'           => $cfg['brand'],
				'threshold'       => $threshold,
				'ratio'           => $ratio,
				'orders'          => $orders,
				'disputes'        => $disputes,
				'disputes_to_hit' => $to_hit,
				'status'          => $status,
			);

			if ( null === $best ) {
				$best = $candidate;
				continue;
			}
			// Prefer the one we're closest to hitting. "Closest" = smallest
			// disputes_to_hit; when already crossed (0), prefer the tightest
			// threshold so the message reflects the most urgent constraint.
			if ( $candidate['disputes_to_hit'] < $best['disputes_to_hit'] ) {
				$best = $candidate;
			} elseif ( $candidate['disputes_to_hit'] === $best['disputes_to_hit']
				&& $candidate['threshold'] < $best['threshold'] ) {
				$best = $candidate;
			}
		}
		return $best;
	}

	/**
	 * Given a status map and list of already-alerted keys, return keys that should fire now.
	 *
	 * @since 1.2.1
	 */
	public static function alerts_to_send( array $status, array $already_alerted ): array {
		$out = array();
		foreach ( $status as $key => $row ) {
			$current = $row['status'] ?? '';
			if ( in_array( $current, array( 'warning', 'danger' ), true )
				&& ! in_array( $key, $already_alerted, true ) ) {
				$out[] = $key;
			}
		}
		return $out;
	}

	/**
	 * Get ratios for both calendar-month and trailing-30-day windows.
	 *
	 * Cached in a 15-minute transient because the dashboard card triggers 12+
	 * order-meta queries per render; dispute events are rare enough that a
	 * 15-minute staleness is acceptable. The cache clears automatically when a
	 * new dispute is recorded via clear_ratios_cache().
	 *
	 * @since 1.2.1
	 * @return array
	 */
	public function get_ratios(): array {
		$cached = get_transient( self::RATIOS_CACHE );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$tz      = wp_timezone();
		$now     = new \DateTimeImmutable( 'now', $tz );
		$month_s = $now->modify( 'first day of this month' )->setTime( 0, 0, 0 );
		$t30_s   = $now->modify( '-30 days' );

		$result = array(
			'calendar_month' => $this->window_ratios( $month_s, $now ),
			'trailing_30'    => $this->window_ratios( $t30_s, $now ),
		);

		set_transient( self::RATIOS_CACHE, $result, self::RATIOS_TTL );
		return $result;
	}

	/**
	 * Clear the ratio + trend transients. Called when a new dispute is recorded
	 * so the dashboard and monitor page reflect the change on the next render.
	 *
	 * @since 1.2.1
	 */
	public static function clear_ratios_cache(): void {
		delete_transient( self::RATIOS_CACHE );
		// Trend cache keys are suffixed by month count; known callers use 12.
		// Sweep the common default plus a handful of other plausible values.
		foreach ( array( 3, 6, 12, 24 ) as $m ) {
			delete_transient( self::TREND_CACHE . '_' . $m );
		}
	}

	/**
	 * Monthly ratio buckets for the last N months (oldest first) for the trend chart.
	 *
	 * Cached in a 1-hour transient — each uncached call triggers ~60 order-meta
	 * queries (12 months × 5 brands) plus N order lookups per dispute. Cache
	 * clears whenever a new dispute is recorded via clear_ratios_cache().
	 *
	 * @since 1.2.1
	 * @return array
	 */
	public function get_trend( int $months = 12 ): array {
		$cache_key = self::TREND_CACHE . '_' . $months;
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$tz     = wp_timezone();
		$anchor = ( new \DateTimeImmutable( 'now', $tz ) )->modify( 'first day of this month' )->setTime( 0, 0, 0 );
		$buckets = array();

		for ( $i = $months - 1; $i >= 0; $i-- ) {
			$start = $anchor->modify( "-{$i} months" );
			$end   = $start->modify( 'first day of next month' )->modify( '-1 second' );
			$row   = $this->window_ratios( $start, $end );
			$buckets[] = array(
				'month'    => $start->format( 'Y-m' ),
				'orders'   => (int) $row['blended']['orders'],
				'disputes' => (int) $row['blended']['disputes'],
				'ratio'    => (float) $row['blended']['ratio'],
			);
		}

		set_transient( $cache_key, $buckets, self::TREND_TTL );
		return $buckets;
	}

	/**
	 * Daily cron handler. Fires alerts for thresholds crossed this calendar month.
	 *
	 * @since 1.2.1
	 */
	public function check_and_alert(): void {
		$ratios   = $this->get_ratios();
		$warn_pct = (int) get_option( self::OPTION_WARN_PCT, 70 );
		$status   = self::get_threshold_status( $ratios['calendar_month'], $warn_pct );

		// Key the dedup record on the site-timezone month so it matches the
		// calendar-month window get_ratios() measures (also wp_timezone()).
		// Using gmdate() here would disagree near month boundaries on non-UTC
		// sites, causing a missed or duplicated alert at the rollover.
		$month_key     = wp_date( 'Y-m' );
		$alerts_option = get_option( self::OPTION_ALERTS, array() );
		if ( ! is_array( $alerts_option ) ) {
			$alerts_option = array();
		}
		$already = $alerts_option[ $month_key ] ?? array();

		$to_send = self::alerts_to_send( $status, $already );
		if ( empty( $to_send ) ) {
			return;
		}

		foreach ( $to_send as $key ) {
			$this->send_alert_email( $status[ $key ] );
			$already[] = $key;
		}

		$alerts_option[ $month_key ] = $already;
		krsort( $alerts_option );
		$alerts_option = array_slice( $alerts_option, 0, 3, true );
		update_option( self::OPTION_ALERTS, $alerts_option );
	}

	/**
	 * Compute blended + per-brand ratios for a single window.
	 */
	private function window_ratios( \DateTimeImmutable $start, \DateTimeImmutable $end ): array {
		$orders_by_brand   = $this->count_card_orders( $start, $end );
		$disputes_by_brand = $this->count_disputes( $start, $end );

		$brand_rows = array();
		$total_o    = 0;
		$total_d    = 0;

		foreach ( self::BRANDS_MAJOR as $brand ) {
			$o = (int) ( $orders_by_brand[ $brand ] ?? 0 );
			$d = (int) ( $disputes_by_brand[ $brand ] ?? 0 );
			$total_o += $o;
			$total_d += $d;
			$brand_rows[] = array(
				'brand'    => $brand,
				'orders'   => $o,
				'disputes' => $d,
				'ratio'    => self::compute_ratio( $o, $d ),
			);
		}

		// 'other' goes only into the blended total.
		$total_o += (int) ( $orders_by_brand['other'] ?? 0 );
		$total_d += (int) ( $disputes_by_brand['other'] ?? 0 );

		return array(
			'blended' => array(
				'orders'   => $total_o,
				'disputes' => $total_d,
				'ratio'    => self::compute_ratio( $total_o, $total_d ),
			),
			'brands'  => $brand_rows,
		);
	}

	/** Major brand list (mirrors Card_Brand_Detector::BRANDS for query loops). */
	const BRANDS_MAJOR = array( 'visa', 'mastercard', 'amex', 'discover' );

	/**
	 * Count card-transaction orders per brand in a window.
	 */
	private function count_card_orders( \DateTimeImmutable $start, \DateTimeImmutable $end ): array {
		$counts = array();
		$after  = $start->format( 'Y-m-d H:i:s' );
		$before = $end->format( 'Y-m-d H:i:s' );
		$range  = $after . '...' . $before;

		foreach ( array_merge( self::BRANDS_MAJOR, array( 'other' ) ) as $brand ) {
			$ids = wc_get_orders( array(
				'limit'        => -1,
				'status'       => array( 'completed', 'processing', 'refunded' ),
				'date_created' => $range,
				'meta_query'   => array(
					'relation' => 'AND',
					array(
						'key'   => TrustLens_Card_Brand_Detector::META_CARD_TX,
						'value' => '1',
					),
					array(
						'key'   => TrustLens_Card_Brand_Detector::META_BRAND,
						'value' => $brand,
					),
				),
				'return'       => 'ids',
			) );
			$counts[ $brand ] = is_array( $ids ) ? count( $ids ) : 0;
		}
		return $counts;
	}

	/**
	 * Count disputes per brand in a window by joining dispute events to the
	 * card-brand order meta.
	 */
	private function count_disputes( \DateTimeImmutable $start, \DateTimeImmutable $end ): array {
		global $wpdb;
		$table = $wpdb->prefix . 'trustlens_events';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT order_id FROM {$table}
			 WHERE event_type = %s AND created_at >= %s AND created_at <= %s",
			'dispute_created',
			$start->format( 'Y-m-d H:i:s' ),
			$end->format( 'Y-m-d H:i:s' )
		) );
		// phpcs:enable

		$counts = array_fill_keys(
			array_merge( self::BRANDS_MAJOR, array( 'other', 'unknown' ) ),
			0
		);

		$detector = new TrustLens_Card_Brand_Detector();
		foreach ( $rows as $row ) {
			$brand = $detector->get_brand( (int) $row->order_id );
			if ( ! isset( $counts[ $brand ] ) ) {
				$counts[ $brand ] = 0;
			}
			++$counts[ $brand ];
		}
		return $counts;
	}

	/**
	 * Send a single alert email to the site admin.
	 */
	private function send_alert_email( array $row ): void {
		$to      = get_option( 'admin_email' );
		$label   = $row['label'] ?? __( 'Chargeback threshold', 'trustlens' );
		$brand   = $row['brand'] ?? '';
		$ratio_p = number_format( 100 * (float) ( $row['ratio'] ?? 0 ), 3 );
		$t_p     = number_format( 100 * (float) ( $row['threshold'] ?? 0 ), 2 );
		$status  = $row['status'] ?? 'warning';

		$subject = sprintf(
			/* translators: 1: program label, 2: status phrase */
			__( '[TrustLens] %1$s — %2$s', 'trustlens' ),
			$label,
			'danger' === $status ? __( 'threshold crossed', 'trustlens' ) : __( 'approaching threshold', 'trustlens' )
		);

		$body = sprintf(
			/* translators: 1: brand, 2: ratio %, 3: program label, 4: threshold % */
			__( 'Your %1$s chargeback ratio this month is %2$s%%. The %3$s threshold is %4$s%%.', 'trustlens' ),
			strtoupper( $brand ),
			$ratio_p,
			$label,
			$t_p
		);

		$link = admin_url( 'admin.php?page=trustlens-chargeback-monitor' );
		/* translators: %s: URL to the Chargeback Monitor admin page. */
		$body .= "\n\n" . sprintf( __( 'View details: %s', 'trustlens' ), $link );

		wp_mail( $to, $subject, $body );
	}
}
