<?php
/**
 * Dispute repository — per-dispute persistence for the chargeback worklist.
 *
 * @package TrustLens
 * @since   1.3.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Reads/writes individual chargeback disputes in the trustlens_disputes table.
 *
 * The per-customer counters on the customers table (total_disputes/won/lost)
 * remain the source of truth for trust scoring; this table powers the Open
 * Disputes deadline worklist and later representment phases.
 *
 * @since 1.3.0
 */
class TrustLens_Dispute_Repository {

	/**
	 * Disputes table name.
	 *
	 * @var string
	 */
	private string $table;

	/**
	 * Customers table name (for worklist joins).
	 *
	 * @var string
	 */
	private string $customers;

	/**
	 * Constructor.
	 */
	public function __construct() {
		global $wpdb;
		$this->table     = $wpdb->prefix . 'trustlens_disputes';
		$this->customers = $wpdb->prefix . 'trustlens_customers';
	}

	/**
	 * Insert a dispute or update the existing row with the same dispute_id.
	 *
	 * On update, null/empty incoming values are skipped so a partial webhook
	 * payload (e.g. a status-only change) never wipes amount/reason/due_by. The
	 * original opened_at is always preserved.
	 *
	 * @since 1.3.0
	 * @param array $data Dispute fields: dispute_id (required), order_id,
	 *                    email_hash, gateway, reason, amount, currency, status,
	 *                    is_open (bool, default true), outcome, due_by.
	 * @return int Row id (0 on failure).
	 */
	public function upsert_dispute( array $data ): int {
		global $wpdb;

		$dispute_id = isset( $data['dispute_id'] ) ? substr( sanitize_text_field( (string) $data['dispute_id'] ), 0, 191 ) : '';
		if ( '' === $dispute_id ) {
			return 0;
		}

		// Write format per column. Single source of truth for both paths.
		$formats = array(
			'dispute_id' => '%s',
			'order_id'   => '%d',
			'email_hash' => '%s',
			'gateway'    => '%s',
			'reason'     => '%s',
			'amount'     => '%f',
			'currency'   => '%s',
			'status'     => '%s',
			'is_open'    => '%d',
			'outcome'    => '%s',
			'due_by'     => '%s',
		);

		// Sanitize ONLY the columns the caller actually supplied. Keys absent
		// from $data stay absent here, which is what lets a partial webhook
		// payload update a few fields without resetting the rest to defaults.
		$provided = array();
		if ( isset( $data['order_id'] ) ) {
			$provided['order_id'] = absint( $data['order_id'] );
		}
		if ( isset( $data['email_hash'] ) ) {
			$provided['email_hash'] = sanitize_text_field( (string) $data['email_hash'] );
		}
		if ( isset( $data['gateway'] ) ) {
			$provided['gateway'] = sanitize_key( (string) $data['gateway'] );
		}
		if ( isset( $data['reason'] ) ) {
			$provided['reason'] = substr( sanitize_text_field( (string) $data['reason'] ), 0, 50 );
		}
		if ( isset( $data['amount'] ) ) {
			$provided['amount'] = (float) $data['amount'];
		}
		if ( isset( $data['currency'] ) ) {
			$provided['currency'] = substr( sanitize_text_field( (string) $data['currency'] ), 0, 3 );
		}
		if ( isset( $data['status'] ) ) {
			$provided['status'] = substr( sanitize_text_field( (string) $data['status'] ), 0, 30 );
		}
		if ( array_key_exists( 'is_open', $data ) ) {
			$provided['is_open'] = $data['is_open'] ? 1 : 0;
		}
		if ( isset( $data['outcome'] ) ) {
			$provided['outcome'] = substr( sanitize_text_field( (string) $data['outcome'] ), 0, 10 );
		}
		if ( ! empty( $data['due_by'] ) ) {
			$provided['due_by'] = (string) $data['due_by'];
		}

		$existing = $this->get_dispute( $dispute_id );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table from $wpdb->prefix.
		if ( $existing ) {
			// Partial update: apply only supplied, non-empty columns so an
			// incremental payload never overwrites existing values. email_hash
			// and dispute_id are identity columns and never reassigned.
			$update = array();
			$ufmt   = array();
			foreach ( $provided as $key => $value ) {
				if ( 'email_hash' === $key ) {
					continue;
				}
				if ( null === $value || '' === $value ) {
					continue;
				}
				$update[ $key ] = $value;
				$ufmt[]         = $formats[ $key ];
			}
			if ( ! empty( $update ) ) {
				$wpdb->update( $this->table, $update, array( 'dispute_id' => $dispute_id ), $ufmt, array( '%s' ) );
			}
			return (int) $existing->id;
		}

		// Insert: documented defaults, overlaid with whatever the caller supplied.
		$fields = array(
			'dispute_id' => $dispute_id,
			'order_id'   => null,
			'email_hash' => '',
			'gateway'    => 'manual',
			'reason'     => null,
			'amount'     => 0.0,
			'currency'   => null,
			'status'     => 'needs_response',
			'is_open'    => 1,
			'outcome'    => null,
			'due_by'     => null,
		);
		foreach ( $provided as $key => $value ) {
			$fields[ $key ] = $value;
		}

		$insert_formats = array();
		foreach ( $fields as $key => $value ) {
			$insert_formats[] = $formats[ $key ];
		}

		$wpdb->insert( $this->table, $fields, $insert_formats );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		return (int) $wpdb->insert_id;
	}

	/**
	 * Mark a dispute closed with an outcome.
	 *
	 * @since 1.3.0
	 * @param string $dispute_id Gateway dispute id.
	 * @param string $outcome    'won' or 'lost'.
	 * @param string $status     Gateway status label.
	 * @return bool True if a row was updated.
	 */
	public function mark_closed( string $dispute_id, string $outcome, string $status = 'closed' ): bool {
		global $wpdb;

		$dispute_id = substr( sanitize_text_field( $dispute_id ), 0, 191 );
		$outcome    = in_array( $outcome, array( 'won', 'lost' ), true ) ? $outcome : null;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table from $wpdb->prefix.
		$updated = $wpdb->update(
			$this->table,
			array(
				'is_open'   => 0,
				'outcome'   => $outcome,
				'status'    => substr( sanitize_text_field( $status ), 0, 30 ),
				'closed_at' => current_time( 'mysql' ),
			),
			array( 'dispute_id' => $dispute_id ),
			array( '%d', '%s', '%s', '%s' ),
			array( '%s' )
		);

		return (bool) $updated;
	}

	/**
	 * Get a single dispute by gateway id.
	 *
	 * @since 1.3.0
	 * @param string $dispute_id Gateway dispute id.
	 * @return object|null
	 */
	public function get_dispute( string $dispute_id ): ?object {
		global $wpdb;
		$dispute_id = substr( sanitize_text_field( $dispute_id ), 0, 191 );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table from $wpdb->prefix; value prepared.
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->table} WHERE dispute_id = %s", $dispute_id ) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		return $row ?: null;
	}

	/**
	 * Get open disputes, soonest deadline first, with customer context.
	 *
	 * @since 1.3.0
	 * @param int $limit Max rows.
	 * @return array Array of row objects.
	 */
	public function get_open_disputes( int $limit = 100 ): array {
		global $wpdb;
		$limit = max( 1, min( 500, $limit ) );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Tables from $wpdb->prefix; limit cast int.
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT d.*, c.customer_email, c.segment, c.trust_score, c.is_blocked
			 FROM {$this->table} d
			 LEFT JOIN {$this->customers} c ON c.email_hash = d.email_hash
			 WHERE d.is_open = 1
			 ORDER BY (d.due_by IS NULL) ASC, d.due_by ASC, d.opened_at ASC
			 LIMIT %d",
			$limit
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Count open disputes, optionally only those due within N hours.
	 *
	 * @since 1.3.0
	 * @param int $due_within_hours Window in hours (0 = all open).
	 * @return int
	 */
	public function count_open( int $due_within_hours = 0 ): int {
		global $wpdb;

		if ( $due_within_hours > 0 ) {
			$cutoff = gmdate( 'Y-m-d H:i:s', time() + ( $due_within_hours * HOUR_IN_SECONDS ) );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table from $wpdb->prefix; value prepared.
			$count = $wpdb->get_var( $wpdb->prepare(
				"SELECT COUNT(*) FROM {$this->table} WHERE is_open = 1 AND due_by IS NOT NULL AND due_by <= %s",
				$cutoff
			) );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			return (int) $count;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table from $wpdb->prefix.
		$count = $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table} WHERE is_open = 1" );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		return (int) $count;
	}
}
