<?php
/**
 * Chargeback/Dispute tracking module.
 *
 * @package TrustLens\Modules
 * @since   1.2.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Chargebacks module class.
 *
 * Detects chargeback/dispute patterns from payment gateways:
 * - Stripe disputes via webhook
 * - WooPayments disputes via webhook
 * - Manual chargeback recording
 *
 * @since 1.2.0
 */
class TrustLens_Module_Chargebacks extends TrustLens_Module {

	/**
	 * Module ID.
	 *
	 * @var string
	 */
	protected string $id = 'chargebacks';

	/**
	 * Module name.
	 *
	 * @var string
	 */
	protected string $name = 'Chargeback Tracking';

	/**
	 * Module description.
	 *
	 * @var string
	 */
	protected string $description = 'Tracks chargebacks and disputes from Stripe and WooPayments.';

	/**
	 * Is Pro feature.
	 *
	 * Free as of 1.2.1 — basic dispute tracking ships with all builds so that the
	 * blended chargeback-ratio speedometer on the dashboard has real data. Pro
	 * layers (alerts, trend chart, per-brand breakdown, dedicated monitor page)
	 * are gated at the UI and cron-wiring layer, not here.
	 *
	 * @var bool
	 */
	protected bool $is_pro = false;

	/**
	 * Register hooks.
	 *
	 * @since 1.2.0
	 */
	public function register_hooks(): void {
		// Stripe webhook handler.
		add_action( 'wc_gateway_stripe_process_webhook_payment', array( $this, 'handle_stripe_webhook' ), 10, 2 );

		// WooPayments dispute webhook.
		add_action( 'woocommerce_payments_dispute_created', array( $this, 'handle_woopayments_dispute' ) );
		add_action( 'woocommerce_payments_dispute_closed', array( $this, 'handle_woopayments_dispute_closed' ) );

		// Alternative Stripe integration via charge.dispute hooks.
		add_action( 'wc_stripe_charge_dispute_created', array( $this, 'handle_stripe_dispute_created' ) );
		add_action( 'wc_stripe_charge_dispute_closed', array( $this, 'handle_stripe_dispute_closed' ) );

		// Register AJAX handler for manual chargeback recording.
		add_action( 'wp_ajax_wstl_record_chargeback', array( $this, 'ajax_record_chargeback' ) );

		// Hook into order meta box for chargeback indicator.
		add_action( 'woocommerce_admin_order_data_after_billing_address', array( $this, 'show_chargeback_indicator' ) );
	}

	/**
	 * Handle Stripe webhook for disputes.
	 *
	 * @since 1.2.0
	 * @param string $notification_type Notification type.
	 * @param object $notification      Notification data.
	 */
	public function handle_stripe_webhook( string $notification_type, $notification ): void {
		// Handle charge.dispute.created event.
		if ( 'charge.dispute.created' === $notification_type || 'dispute.created' === $notification_type ) {
			$this->process_dispute_created( $notification );
		}

		// Handle charge.dispute.closed event.
		if ( 'charge.dispute.closed' === $notification_type || 'dispute.closed' === $notification_type ) {
			$this->process_dispute_closed( $notification );
		}
	}

	/**
	 * Process dispute created event from Stripe.
	 *
	 * @since 1.2.0
	 * @param object $notification Stripe notification.
	 */
	private function process_dispute_created( $notification ): void {
		$dispute = $notification->data->object ?? null;

		if ( ! $dispute ) {
			return;
		}

		// Find order by charge ID.
		$order = $this->get_order_by_charge_id( $dispute->charge ?? '' );

		if ( ! $order ) {
			return;
		}

		$customer_key = wstl_get_customer_key( $order );
		$email_hash = $customer_key['email_hash'];

		// Record the dispute.
		$this->record_dispute(
			$email_hash,
			$order->get_id(),
			array(
				'dispute_id'   => $dispute->id ?? '',
				'charge_id'    => $dispute->charge ?? '',
				'amount'       => isset( $dispute->amount ) ? $dispute->amount / 100 : 0, // Stripe amounts are in cents.
				'currency'     => isset( $dispute->currency ) ? strtoupper( (string) $dispute->currency ) : '',
				'reason'       => $dispute->reason ?? 'unknown',
				'status'       => $dispute->status ?? 'needs_response',
				'due_by'       => ! empty( $dispute->evidence_details->due_by )
					? gmdate( 'Y-m-d H:i:s', (int) $dispute->evidence_details->due_by )
					: null,
				'source'       => 'stripe',
			)
		);
	}

	/**
	 * Process dispute closed event from Stripe.
	 *
	 * @since 1.2.0
	 * @param object $notification Stripe notification.
	 */
	private function process_dispute_closed( $notification ): void {
		$dispute = $notification->data->object ?? null;

		if ( ! $dispute ) {
			return;
		}

		// Find order by charge ID.
		$order = $this->get_order_by_charge_id( $dispute->charge ?? '' );

		if ( ! $order ) {
			return;
		}

		$customer_key = wstl_get_customer_key( $order );
		$email_hash = $customer_key['email_hash'];

		// Update dispute outcome.
		$won = in_array( $dispute->status, array( 'won', 'warning_closed' ), true );

		$this->close_dispute(
			$email_hash,
			(string) ( $dispute->id ?? '' ),
			$won,
			(string) ( $dispute->status ?? 'closed' ),
			(int) $order->get_id()
		);
	}

	/**
	 * Handle WooPayments dispute created.
	 *
	 * @since 1.2.0
	 * @param array $dispute Dispute data.
	 */
	public function handle_woopayments_dispute( array $dispute ): void {
		$order_id = $dispute['order_id'] ?? 0;
		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			return;
		}

		$customer_key = wstl_get_customer_key( $order );
		$email_hash = $customer_key['email_hash'];

		$this->record_dispute(
			$email_hash,
			$order->get_id(),
			array(
				'dispute_id' => $dispute['id'] ?? '',
				'amount'     => $dispute['amount'] ?? 0,
				'currency'   => isset( $dispute['currency'] ) ? strtoupper( (string) $dispute['currency'] ) : '',
				'reason'     => $dispute['reason'] ?? 'unknown',
				'status'     => $dispute['status'] ?? 'needs_response',
				'due_by'     => ! empty( $dispute['evidence_details']['due_by'] )
					? gmdate( 'Y-m-d H:i:s', (int) $dispute['evidence_details']['due_by'] )
					: null,
				'source'     => 'woopayments',
			)
		);
	}

	/**
	 * Handle WooPayments dispute closed.
	 *
	 * @since 1.2.0
	 * @param array $dispute Dispute data.
	 */
	public function handle_woopayments_dispute_closed( array $dispute ): void {
		$order_id = $dispute['order_id'] ?? 0;
		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			return;
		}

		$customer_key = wstl_get_customer_key( $order );
		$email_hash = $customer_key['email_hash'];

		$won = 'won' === ( $dispute['status'] ?? '' );

		$this->close_dispute(
			$email_hash,
			(string) ( $dispute['id'] ?? '' ),
			$won,
			(string) ( $dispute['status'] ?? 'closed' ),
			(int) $order_id
		);
	}

	/**
	 * Handle Stripe dispute created (alternative hook).
	 *
	 * @since 1.2.0
	 * @param object $dispute Dispute object.
	 */
	public function handle_stripe_dispute_created( $dispute ): void {
		if ( ! is_object( $dispute ) ) {
			return;
		}

		// Get order from dispute.
		$order_id = $dispute->metadata->order_id ?? 0;
		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			$order = $this->get_order_by_charge_id( $dispute->charge ?? '' );
		}

		if ( ! $order ) {
			return;
		}

		$customer_key = wstl_get_customer_key( $order );
		$email_hash = $customer_key['email_hash'];

		$this->record_dispute(
			$email_hash,
			$order->get_id(),
			array(
				'dispute_id' => $dispute->id ?? '',
				'charge_id'  => $dispute->charge ?? '',
				'amount'     => isset( $dispute->amount ) ? $dispute->amount / 100 : 0,
				'reason'     => $dispute->reason ?? 'unknown',
				'status'     => $dispute->status ?? 'pending',
				'source'     => 'stripe',
			)
		);
	}

	/**
	 * Handle Stripe dispute closed (alternative hook).
	 *
	 * @since 1.2.0
	 * @param object $dispute Dispute object.
	 */
	public function handle_stripe_dispute_closed( $dispute ): void {
		if ( ! is_object( $dispute ) ) {
			return;
		}

		$order_id = $dispute->metadata->order_id ?? 0;
		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			$order = $this->get_order_by_charge_id( $dispute->charge ?? '' );
		}

		if ( ! $order ) {
			return;
		}

		$customer_key = wstl_get_customer_key( $order );
		$email_hash = $customer_key['email_hash'];

		$won = in_array( $dispute->status, array( 'won', 'warning_closed' ), true );

		$this->close_dispute(
			$email_hash,
			(string) ( $dispute->id ?? '' ),
			$won,
			(string) ( $dispute->status ?? 'closed' ),
			(int) $order->get_id()
		);
	}

	/**
	 * Record a dispute in the database.
	 *
	 * @since 1.2.0
	 * @param string      $email_hash Customer email hash.
	 * @param int         $order_id   Order ID.
	 * @param array       $data       Dispute data.
	 * @param string|null $outcome    Optional final outcome ('won' or 'lost'). When provided, the
	 *                                outcome counter is bumped in the SAME atomic UPDATE as
	 *                                total_disputes — eliminates the race window that previously
	 *                                let `disputes_lost` exceed `total_disputes` under concurrent
	 *                                manual entries. Stripe/WooPayments handlers leave this null
	 *                                and update the outcome later via update_dispute_outcome().
	 */
	private function record_dispute( string $email_hash, int $order_id, array $data, ?string $outcome = null ): void {
		global $wpdb;

		$outcome_column = '';
		if ( 'won' === $outcome ) {
			$outcome_column = 'disputes_won';
		} elseif ( 'lost' === $outcome ) {
			$outcome_column = 'disputes_lost';
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table from $wpdb->prefix; $outcome_column is hardcoded ternary value (disputes_won/disputes_lost) — never user input.
		if ( '' !== $outcome_column ) {
			$wpdb->query( $wpdb->prepare(
				"UPDATE {$wpdb->prefix}trustlens_customers
				 SET total_disputes = total_disputes + 1,
				     {$outcome_column} = {$outcome_column} + 1,
				     updated_at = %s
				 WHERE email_hash = %s",
				current_time( 'mysql' ),
				$email_hash
			) );
		} else {
			$wpdb->query( $wpdb->prepare(
				"UPDATE {$wpdb->prefix}trustlens_customers
				 SET total_disputes = total_disputes + 1,
				     updated_at = %s
				 WHERE email_hash = %s",
				current_time( 'mysql' ),
				$email_hash
			) );
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		// Log event.
		$this->log_event(
			$email_hash,
			'dispute_created',
			$data,
			$order_id
		);

		// Invalidate the ratios cache so the dashboard speedometer picks up this dispute immediately.
		if ( class_exists( 'TrustLens_Chargeback_Monitor' ) ) {
			TrustLens_Chargeback_Monitor::clear_ratios_cache();
		}

		// Update order meta via WC_Order so HPOS-enabled stores write to the correct table.
		$order = wc_get_order( $order_id );
		if ( $order ) {
			$order->update_meta_data( '_trustlens_dispute', true );
			$order->update_meta_data( '_trustlens_dispute_data', $data );
			$order->save();
		}

		// Mirror the dispute into the dedicated worklist table (idempotent on
		// dispute_id). The counters/events above remain the source of truth for
		// trust scoring; this row powers the Open Disputes deadline worklist.
		if ( class_exists( 'TrustLens_Dispute_Repository' ) && ! empty( $data['dispute_id'] ) ) {
			$is_open = ! in_array( (string) ( $outcome ?? '' ), array( 'won', 'lost' ), true );
			( new TrustLens_Dispute_Repository() )->upsert_dispute( array(
				'dispute_id' => (string) $data['dispute_id'],
				'order_id'   => $order_id,
				'email_hash' => $email_hash,
				'gateway'    => isset( $data['source'] ) ? (string) $data['source'] : 'manual',
				'reason'     => isset( $data['reason'] ) ? (string) $data['reason'] : null,
				'amount'     => isset( $data['amount'] ) ? (float) $data['amount'] : 0.0,
				'currency'   => isset( $data['currency'] ) ? (string) $data['currency'] : null,
				'status'     => isset( $data['status'] ) ? (string) $data['status'] : 'needs_response',
				'due_by'     => ! empty( $data['due_by'] ) ? (string) $data['due_by'] : null,
				'is_open'    => $is_open,
				'outcome'    => $is_open ? null : (string) $outcome,
			) );
		}

		// Queue score recalculation.
		$this->queue_score_update( $email_hash, 10 );

		// Auto-block check when the dispute was recorded with a known 'lost'
		// outcome — mirrors what update_dispute_outcome() does on later resolutions.
		if ( 'lost' === $outcome && function_exists( 'wstl_can_use_pro' ) && wstl_can_use_pro() ) {
			$this->maybe_auto_block( $email_hash );
		}

		// Trigger action for integrations.
		do_action( 'trustlens/dispute_recorded', $email_hash, $order_id, $data );

		// Trigger notification hook.
		do_action( 'trustlens/chargeback_filed', $email_hash );
	}

	/**
	 * Update dispute outcome.
	 *
	 * @since 1.2.0
	 * @param string $email_hash Customer email hash.
	 * @param bool   $won        Whether the dispute was won.
	 */
	private function update_dispute_outcome( string $email_hash, bool $won ): void {
		global $wpdb;

		$column = $won ? 'disputes_won' : 'disputes_lost';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table from $wpdb->prefix, $column is hardcoded ternary value (disputes_won/disputes_lost).
		$wpdb->query( $wpdb->prepare(
			"UPDATE {$wpdb->prefix}trustlens_customers
			 SET {$column} = {$column} + 1,
			     updated_at = %s
			 WHERE email_hash = %s",
			current_time( 'mysql' ),
			$email_hash
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		// Queue score recalculation.
		$this->queue_score_update( $email_hash, 10 );

		// Auto-block after N lost disputes (Pro). Only fires on losses so a string
		// of pending→won outcomes can't stack toward blocking.
		if ( ! $won && function_exists( 'wstl_can_use_pro' ) && wstl_can_use_pro() ) {
			$this->maybe_auto_block( $email_hash );
		}
	}

	/**
	 * Apply the complete "dispute resolved" sequence for every gateway: bump the
	 * customer's won/lost counter, close the matching worklist row, and log the
	 * closure event.
	 *
	 * This is the single owner of the close sequence. Each gateway close handler
	 * detects won/loss in its own status vocabulary (Stripe: 'won'/'warning_closed',
	 * WooPayments: 'won') and then delegates here, so the three handlers can never
	 * drift apart again — the root cause of the bug where the Stripe charge hook
	 * bumped the counter but left the dispute stuck open in the deadline worklist.
	 *
	 * @since 1.3.0
	 * @param string $email_hash Customer email hash.
	 * @param string $dispute_id Gateway dispute id ('' skips the worklist row).
	 * @param bool   $won        Whether the dispute resolved in the store's favor.
	 * @param string $status     Gateway status label to record.
	 * @param int    $order_id   Order id for the logged event (0 if unknown).
	 */
	private function close_dispute( string $email_hash, string $dispute_id, bool $won, string $status, int $order_id ): void {
		$this->update_dispute_outcome( $email_hash, $won );

		if ( '' !== $dispute_id && class_exists( 'TrustLens_Dispute_Repository' ) ) {
			( new TrustLens_Dispute_Repository() )->mark_closed(
				$dispute_id,
				$won ? 'won' : 'lost',
				'' !== $status ? $status : 'closed'
			);
		}

		$this->log_event(
			$email_hash,
			'dispute_closed',
			array(
				'dispute_id' => $dispute_id,
				'status'     => $status,
				'won'        => $won,
				'order_id'   => $order_id,
			),
			$order_id > 0 ? $order_id : null
		);

		/**
		 * Fires when a dispute is resolved. $won is true for won/warning_closed outcomes.
		 *
		 * @since 1.3.7
		 * @param string $email_hash Customer email hash.
		 * @param bool   $won        Whether the merchant won the dispute.
		 */
		do_action( 'trustlens/dispute_resolved', $email_hash, $won );
	}

	/**
	 * Apply the "auto-block after N lost disputes" Pro setting.
	 *
	 * Reads trustlens_chargebacks_auto_block (0 = disabled, 1–20 otherwise) and
	 * blocks the customer at checkout once their disputes_lost counter reaches
	 * the threshold. Idempotent: never re-blocks an already-blocked customer.
	 *
	 * @since 1.2.1
	 */
	private function maybe_auto_block( string $email_hash ): void {
		$threshold = (int) wstl_get_setting( 'trustlens_chargebacks_auto_block' );
		if ( $threshold <= 0 ) {
			return;
		}
		if ( wstl_is_blocked( $email_hash ) ) {
			return;
		}

		$customer = wstl_get_customer( $email_hash );
		if ( ! $customer ) {
			return;
		}
		$disputes_lost = (int) ( $customer->disputes_lost ?? 0 );
		if ( $disputes_lost < $threshold ) {
			return;
		}

		$reason = sprintf(
			/* translators: %d: lost-dispute count that triggered the block */
			__( 'Auto-blocked after %d lost chargeback(s).', 'trustlens' ),
			$disputes_lost
		);
		wstl_block_customer( $email_hash, $reason );
	}

	/**
	 * Get order by Stripe charge ID.
	 *
	 * @since 1.2.0
	 * @param string $charge_id Stripe charge ID.
	 * @return WC_Order|null Order or null.
	 */
	private function get_order_by_charge_id( string $charge_id ) {
		if ( empty( $charge_id ) ) {
			return null;
		}

		global $wpdb;

		// Check post meta (legacy).
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Querying postmeta to find order by charge ID.
		$order_id = $wpdb->get_var( $wpdb->prepare(
			"SELECT post_id FROM {$wpdb->postmeta}
			 WHERE meta_key IN ('_stripe_charge_id', '_transaction_id')
			   AND meta_value = %s
			 LIMIT 1",
			$charge_id
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		// Check HPOS orders table.
		if ( ! $order_id && class_exists( 'Automattic\WooCommerce\Utilities\OrderUtil' ) ) {
			if ( Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) {
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
				$order_id = $wpdb->get_var( $wpdb->prepare(
					"SELECT order_id FROM {$wpdb->prefix}wc_orders_meta
					 WHERE meta_key IN ('_stripe_charge_id', '_transaction_id')
					   AND meta_value = %s
					 LIMIT 1",
					$charge_id
				) );
				// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			}
		}

		return $order_id ? wc_get_order( $order_id ) : null;
	}

	/**
	 * AJAX handler for manual chargeback recording.
	 *
	 * @since 1.2.0
	 */
	public function ajax_record_chargeback(): void {
		check_ajax_referer( 'trustlens_admin', 'nonce' );

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'trustlens' ) ) );
		}

		$order_id = absint( $_POST['order_id'] ?? 0 );
		$amount = floatval( $_POST['amount'] ?? 0 );
		$reason = sanitize_text_field( wp_unslash( $_POST['reason'] ?? '' ) );
		$outcome = sanitize_text_field( wp_unslash( $_POST['outcome'] ?? 'pending' ) );
		$card_brand = sanitize_text_field( wp_unslash( $_POST['card_brand'] ?? '' ) );
		$due_date   = sanitize_text_field( wp_unslash( $_POST['due_date'] ?? '' ) );

		if ( ! $order_id ) {
			wp_send_json_error( array( 'message' => __( 'Invalid order ID.', 'trustlens' ) ) );
		}

		$order = wc_get_order( $order_id );

		if ( ! $order ) {
			wp_send_json_error( array( 'message' => __( 'Order not found.', 'trustlens' ) ) );
		}

		// Persist card brand on the order so the Chargeback Monitor can bucket it per brand.
		if ( '' !== $card_brand && class_exists( 'TrustLens_Card_Brand_Detector' ) ) {
			$normalized = TrustLens_Card_Brand_Detector::normalize_brand( $card_brand );
			$order->update_meta_data( TrustLens_Card_Brand_Detector::META_BRAND, $normalized );
			$order->update_meta_data( TrustLens_Card_Brand_Detector::META_CARD_TX, '1' );
			$order->save();
		}

		$customer_key = wstl_get_customer_key( $order );
		$email_hash = $customer_key['email_hash'];

		// Pass outcome into record_dispute so total_disputes and the outcome
		// counter (disputes_won / disputes_lost) update in one atomic UPDATE —
		// avoids the race where two concurrent AJAX calls could let disputes_lost
		// exceed total_disputes.
		$final_outcome = ( 'won' === $outcome || 'lost' === $outcome ) ? $outcome : null;
		$this->record_dispute(
			$email_hash,
			$order_id,
			array(
				'dispute_id' => 'manual_' . time(),
				'amount'     => $amount ?: $order->get_total(),
				'reason'     => $reason ?: 'manual_entry',
				'status'     => $outcome,
				'source'     => 'manual',
				'due_by'     => '' !== $due_date ? gmdate( 'Y-m-d H:i:s', (int) strtotime( $due_date . ' 23:59:59' ) ) : null,
			),
			$final_outcome
		);

		wp_send_json_success( array(
			'message' => __( 'Chargeback recorded successfully.', 'trustlens' ),
		) );
	}

	/**
	 * Show dispute indicator or manual-record form on the order edit page.
	 *
	 * Renders the existing chargeback notice when a dispute has already been
	 * recorded; otherwise renders a collapsible form so admins can record a
	 * manual chargeback (useful for stores whose gateway doesn't push dispute
	 * webhooks to WooCommerce — e.g., offline processors, PayPal, Square).
	 *
	 * @since 1.2.0
	 * @param WC_Order $order Order object.
	 */
	public function show_chargeback_indicator( WC_Order $order ): void {
		$order_id    = $order->get_id();
		$has_dispute = $order->get_meta( '_trustlens_dispute' );

		if ( $has_dispute ) {
			$data      = $order->get_meta( '_trustlens_dispute_data' );
			$cb_reason = isset( $data['reason'] ) ? ucfirst( str_replace( '_', ' ', (string) $data['reason'] ) ) : '';
			$cb_source = isset( $data['source'] ) ? ucfirst( (string) $data['source'] ) : '';
			$cb_amount = isset( $data['amount'] ) ? (float) $data['amount'] : 0.0;
			$cb_curr   = isset( $data['currency'] ) ? (string) $data['currency'] : '';

			$cb_bits = array();
			if ( '' !== $cb_reason ) {
				$cb_bits[] = $cb_reason;
			}
			if ( $cb_amount > 0 && function_exists( 'wc_price' ) ) {
				$cb_bits[] = wp_strip_all_tags( wc_price( $cb_amount, $cb_curr ? array( 'currency' => $cb_curr ) : array() ) );
			}
			if ( '' !== $cb_source ) {
				/* translators: %s: how the dispute was recorded, e.g. "Manual" or "Stripe". */
				$cb_bits[] = sprintf( __( '%s entry', 'trustlens' ), $cb_source );
			}
			?>
			<div class="wstl-cb wstl-order-dispute-indicator">
				<style>
				.wstl-cb{display:flex;gap:10px;align-items:flex-start;margin-top:10px;padding:10px 12px;border-radius:6px;background:#fdecef;border:1px solid #f3c2cb;border-left:3px solid #B23A48}
				.wstl-cb .dashicons{flex:none;margin-top:1px;color:#B23A48;font-size:18px;width:18px;height:18px}
				.wstl-cb__t{display:block;font-size:13px;font-weight:700;color:#8a2733;line-height:1.4}
				.wstl-cb__m{display:block;margin-top:1px;font-size:12px;color:#9b4751}
				</style>
				<span class="dashicons dashicons-warning" aria-hidden="true"></span>
				<span class="wstl-cb__b">
					<span class="wstl-cb__t"><?php esc_html_e( 'Chargeback / dispute filed', 'trustlens' ); ?></span>
					<?php if ( ! empty( $cb_bits ) ) : ?>
						<span class="wstl-cb__m"><?php echo esc_html( implode( '  ·  ', $cb_bits ) ); ?></span>
					<?php endif; ?>
				</span>
			</div>
			<?php
			return;
		}

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return;
		}

		$order_total = (float) $order->get_total();
		?>
		<div class="wstl-manual-chargeback" style="margin-top:12px;padding:10px;border:1px solid #e2e4e7;border-radius:4px;background:#fafafa;">
			<p style="margin:0 0 8px;">
				<button type="button" class="button" id="wstl-manual-chargeback-toggle" data-order-id="<?php echo esc_attr( (string) $order_id ); ?>">
					<?php esc_html_e( 'Record manual chargeback', 'trustlens' ); ?>
				</button>
				<span style="color:#666;font-size:12px;margin-left:6px;">
					<?php esc_html_e( 'Use this if you received a chargeback notice outside of Stripe or WooPayments.', 'trustlens' ); ?>
				</span>
			</p>
			<div id="wstl-manual-chargeback-form" style="display:none;margin-top:8px;">
				<p>
					<label for="wstl-cb-amount" style="display:block;font-weight:600;">
						<?php esc_html_e( 'Amount', 'trustlens' ); ?>
					</label>
					<input type="number" step="0.01" min="0" id="wstl-cb-amount" value="<?php echo esc_attr( (string) $order_total ); ?>" class="widefat" style="max-width:200px;">
				</p>
				<p>
					<label for="wstl-cb-brand" style="display:block;font-weight:600;">
						<?php esc_html_e( 'Card brand', 'trustlens' ); ?>
					</label>
					<select id="wstl-cb-brand">
						<option value=""><?php esc_html_e( 'Unknown', 'trustlens' ); ?></option>
						<option value="visa">Visa</option>
						<option value="mastercard">Mastercard</option>
						<option value="amex">Amex</option>
						<option value="discover">Discover</option>
						<option value="other"><?php esc_html_e( 'Other', 'trustlens' ); ?></option>
					</select>
				</p>
				<p>
					<label for="wstl-cb-reason" style="display:block;font-weight:600;">
						<?php esc_html_e( 'Reason', 'trustlens' ); ?>
					</label>
					<input type="text" id="wstl-cb-reason" class="widefat" style="max-width:400px;" placeholder="<?php esc_attr_e( 'e.g. product_not_received, fraud, duplicate', 'trustlens' ); ?>">
				</p>
				<p>
					<label for="wstl-cb-outcome" style="display:block;font-weight:600;">
						<?php esc_html_e( 'Outcome', 'trustlens' ); ?>
					</label>
					<select id="wstl-cb-outcome">
						<option value="pending"><?php esc_html_e( 'Pending', 'trustlens' ); ?></option>
						<option value="lost"><?php esc_html_e( 'Lost', 'trustlens' ); ?></option>
						<option value="won"><?php esc_html_e( 'Won', 'trustlens' ); ?></option>
					</select>
				</p>
				<p>
					<button type="button" class="button button-primary" id="wstl-cb-submit"><?php esc_html_e( 'Save chargeback', 'trustlens' ); ?></button>
					<button type="button" class="button" id="wstl-cb-cancel"><?php esc_html_e( 'Cancel', 'trustlens' ); ?></button>
					<span id="wstl-cb-feedback" style="margin-left:10px;"></span>
				</p>
			</div>
		</div>
		<script>
		(function($){
			var nonce = '<?php echo esc_js( wp_create_nonce( 'trustlens_admin' ) ); ?>';
			var orderId = <?php echo (int) $order_id; ?>;

			$('#wstl-manual-chargeback-toggle').on('click', function(){
				$('#wstl-manual-chargeback-form').toggle();
			});
			$('#wstl-cb-cancel').on('click', function(){
				$('#wstl-manual-chargeback-form').hide();
				$('#wstl-cb-feedback').text('').css('color','');
			});
			$('#wstl-cb-submit').on('click', function(){
				var $btn = $(this);
				var $fb  = $('#wstl-cb-feedback');
				$btn.prop('disabled', true);
				$fb.text('<?php echo esc_js( __( 'Saving…', 'trustlens' ) ); ?>').css('color','#555');
				$.post(ajaxurl, {
					action:     'wstl_record_chargeback',
					nonce:      nonce,
					order_id:   orderId,
					amount:     $('#wstl-cb-amount').val(),
					card_brand: $('#wstl-cb-brand').val(),
					reason:     $('#wstl-cb-reason').val(),
					outcome:    $('#wstl-cb-outcome').val()
				}).done(function(resp){
					if (resp && resp.success) {
						$fb.text('<?php echo esc_js( __( 'Saved. Reloading…', 'trustlens' ) ); ?>').css('color','#2e7d32');
						setTimeout(function(){ location.reload(); }, 800);
					} else {
						$fb.text((resp && resp.data && resp.data.message) || '<?php echo esc_js( __( 'Error saving chargeback.', 'trustlens' ) ); ?>').css('color','#c62828');
						$btn.prop('disabled', false);
					}
				}).fail(function(){
					$fb.text('<?php echo esc_js( __( 'Request failed.', 'trustlens' ) ); ?>').css('color','#c62828');
					$btn.prop('disabled', false);
				});
			});
		})(jQuery);
		</script>
		<?php
	}

	/**
	 * Calculate signal score for customer.
	 *
	 * @since 1.2.0
	 * @param string $email_hash Customer email hash.
	 * @return array Signal data.
	 */
	public function get_signal( string $email_hash, ?object $customer = null ): array {
		$customer = $customer ?? wstl_get_customer( $email_hash );

		if ( ! $customer ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		// Match returns/shipping/coupons modules — skip scoring for customers
		// below the confident-data threshold so a single legitimate dispute on a
		// brand-new account doesn't trip a -30 penalty before any other signals exist.
		$min_orders = (int) get_option( 'trustlens_min_orders', 3 );
		if ( (int) $customer->total_orders < $min_orders ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		$score = 0;
		$reasons = array();

		$total_disputes = (int) $customer->total_disputes;
		$disputes_lost = (int) $customer->disputes_lost;
		$disputes_won = (int) $customer->disputes_won;
		$total_orders = (int) $customer->total_orders;

		// Any chargeback is a major red flag.
		if ( $total_disputes > 0 ) {
			// Lost disputes are very serious.
			if ( $disputes_lost >= 3 ) {
				$score -= 50;
				$reasons[] = sprintf(
					/* translators: %d: number of lost chargebacks */
					__( '%d chargebacks lost (major risk)', 'trustlens' ),
					$disputes_lost
				);
			} elseif ( $disputes_lost >= 2 ) {
				$score -= 40;
				$reasons[] = sprintf(
					/* translators: %d: number of lost chargebacks */
					__( '%d chargebacks lost', 'trustlens' ),
					$disputes_lost
				);
			} elseif ( $disputes_lost >= 1 ) {
				$score -= 30;
				$reasons[] = __( 'Has lost chargeback', 'trustlens' );
			}

			// Pending/ongoing disputes.
			$pending_disputes = $total_disputes - $disputes_won - $disputes_lost;
			if ( $pending_disputes > 0 ) {
				$score -= 20;
				$reasons[] = sprintf(
					/* translators: %d: number of pending disputes */
					__( '%d pending dispute(s)', 'trustlens' ),
					$pending_disputes
				);
			}

			// Won disputes are still a slight flag (customer filed dispute).
			if ( $disputes_won > 0 && $disputes_lost === 0 ) {
				$score -= 5;
				$reasons[] = __( 'Previously disputed (won)', 'trustlens' );
			}

			// Dispute rate relative to orders.
			if ( $total_orders >= 5 ) {
				$dispute_rate = ( $total_disputes / $total_orders ) * 100;
				if ( $dispute_rate >= 10 ) {
					$score -= 15;
					$reasons[] = sprintf(
						/* translators: %.0f: dispute rate percentage */
						__( 'High dispute rate: %.0f%%', 'trustlens' ),
						$dispute_rate
					);
				}
			}
		}

		// Positive: Long history with no disputes.
		if ( $total_orders >= 10 && $total_disputes === 0 ) {
			$score += 10;
			$reasons[] = __( 'No dispute history', 'trustlens' );
		}

		return array(
			'score'  => $score,
			'reason' => implode( '; ', $reasons ),
		);
	}

	/**
	 * Get settings fields.
	 *
	 * @since 1.2.0
	 * @return array Settings fields.
	 */
	public function get_settings_fields(): array {
		return array(
			array(
				'id'      => 'chargebacks_auto_block',
				'title'   => __( 'Auto-block after disputes', 'trustlens' ),
				'type'    => 'number',
				'default' => 2,
				'desc'    => __( 'Number of lost disputes before auto-blocking customer (0 = disabled)', 'trustlens' ),
			),
		);
	}

	/**
	 * Get customer dispute history.
	 *
	 * @since 1.2.0
	 * @param string $email_hash Customer email hash.
	 * @return array Dispute events.
	 */
	public static function get_dispute_history( 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_events
			 WHERE email_hash = %s
			   AND event_type IN ('dispute_created', 'dispute_closed')
			 ORDER BY created_at DESC
			 LIMIT 50",
			$email_hash
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	/**
	 * Get total dispute stats for the store.
	 *
	 * @since 1.2.0
	 * @return array Stats array.
	 */
	public static function get_dispute_stats(): array {
		global $wpdb;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Aggregate stats query, table name from $wpdb->prefix is safe.
		$stats = $wpdb->get_row(
			"SELECT
				SUM(total_disputes) as total_disputes,
				SUM(disputes_won) as disputes_won,
				SUM(disputes_lost) as disputes_lost,
				COUNT(CASE WHEN total_disputes > 0 THEN 1 END) as customers_with_disputes
			 FROM {$wpdb->prefix}trustlens_customers"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		return array(
			'total_disputes'          => (int) ( $stats->total_disputes ?? 0 ),
			'disputes_won'            => (int) ( $stats->disputes_won ?? 0 ),
			'disputes_lost'           => (int) ( $stats->disputes_lost ?? 0 ),
			'customers_with_disputes' => (int) ( $stats->customers_with_disputes ?? 0 ),
		);
	}
}
