<?php
/**
 * Payment-gateway tracking-accuracy report.
 *
 * @package Carticy\Conversions\Diagnostics
 */

declare(strict_types=1);

namespace Carticy\Conversions\Diagnostics;

use Carticy\Conversions\Domain\ConversionEventType;
use Carticy\Conversions\Storage\OrderMetaStore;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Reconciles WooCommerce paid orders against orders that actually recorded a
 * canonical purchase event, grouped by payment gateway. This diagnostic is
 * computed from real orders, never assumed from configuration, and it names
 * the gateway behind any gap.
 *
 * "Tracked" is defined honestly as "a `purchase` canonical event was recorded on
 * the order" (the order-paid engine fired). The query is bounded and paginated
 * so a large store cannot turn this into an unbounded scan.
 */
final class GatewayAccuracyReport {

	/**
	 * Fallback paid statuses when WooCommerce helpers are unavailable (tests).
	 */
	private const FALLBACK_PAID_STATUSES = array( 'processing', 'completed' );

	/**
	 * Max order pages scanned (page size �- this bounds the work).
	 */
	private const MAX_PAGES = 50;

	/**
	 * Page size for the paid-order query.
	 */
	private const PAGE_SIZE = 100;

	/**
	 * Transient cache key prefix and TTL (a diagnostic, not real-time).
	 */
	private const CACHE_PREFIX = 'carticy_conversions_accuracy_';
	private const CACHE_TTL    = 300;

	/**
	 * Max untracked order ids surfaced per gateway. The cap keeps diagnostic
	 * payloads bounded while preserving enough examples to investigate.
	 */
	private const MAX_GAP_ORDER_IDS = 10;

	/**
	 * Order meta store.
	 *
	 * @var OrderMetaStore
	 */
	private OrderMetaStore $store;

	/**
	 * Constructor.
	 *
	 * @param OrderMetaStore $store Order meta store.
	 */
	public function __construct( OrderMetaStore $store ) {
		$this->store = $store;
	}

	/**
	 * Build the accuracy report over a recent window.
	 *
	 * @param int $window_days Lookback window in days.
	 * @return array<string, mixed>
	 */
	public function build( int $window_days = 30 ): array {
		$window_days = max( 1, min( 365, $window_days ) );

		// A diagnostic surface, not a real-time figure: cache briefly so an admin
		// page load does not re-scan the order table on every request (the scan
		// is bounded but still touches order meta for every paid order).
		$cache_key = self::CACHE_PREFIX . $window_days;
		if ( function_exists( 'get_transient' ) ) {
			$cached = get_transient( $cache_key );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}

		if ( ! function_exists( 'wc_get_orders' ) ) {
			return $this->empty_report( $window_days );
		}

		$cutoff = time() - ( $window_days * DAY_IN_SECONDS );

		// Orders paid before the plugin existed can never have recorded an
		// event - counting them as gaps would misdiagnose every fresh install
		// as "purchases missed" (the atamember resolve_flow_status lesson:
		// never declare a failure for work that predates the system).
		$activated_at = (int) get_option( 'carticy_conversions_activated_at', 0 );
		$clamped      = $activated_at > $cutoff;
		if ( $clamped ) {
			$cutoff = $activated_at;
		}

		$cutoff_string = gmdate( 'Y-m-d H:i:s', $cutoff );
		$gateways      = array();
		$scanned       = 0;
		$capped        = false;

		for ( $page = 1; $page <= self::MAX_PAGES; $page++ ) {
			$orders = $this->fetch_paid_orders( $page, $cutoff_string );
			if ( array() === $orders ) {
				break;
			}

			foreach ( $orders as $order ) {
				if ( ! is_object( $order ) || ! $this->is_paid( $order ) || ! $this->within_window( $order, $cutoff ) ) {
					continue;
				}

				++$scanned;
				$this->accumulate( $gateways, $order );
			}

			if ( count( $orders ) < self::PAGE_SIZE ) {
				break;
			}

			if ( self::MAX_PAGES === $page ) {
				$capped = true;
			}
		}

		$report = $this->finalize( $gateways, $window_days, $scanned, $capped, $cutoff_string, $clamped );
		if ( function_exists( 'set_transient' ) ) {
			set_transient( $cache_key, $report, self::CACHE_TTL );
		}

		return $report;
	}

	/**
	 * Fetch one page of recent paid orders within the window.
	 *
	 * @param int    $page Page number.
	 * @param string $cutoff_string GMT datetime lower bound for date_paid.
	 * @return array<int, object>
	 */
	private function fetch_paid_orders( int $page, string $cutoff_string ): array {
		$result = wc_get_orders(
			array(
				'status'    => $this->paid_statuses(),
				'date_paid' => '>=' . $cutoff_string,
				'limit'     => self::PAGE_SIZE,
				'page'      => $page,
				'orderby'   => 'date',
				'order'     => 'DESC',
				'return'    => 'objects',
			)
		);

		if ( is_object( $result ) && isset( $result->orders ) && is_array( $result->orders ) ) {
			return $result->orders;
		}

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

	/**
	 * Whether the order is paid, using the same definition as the order-paid
	 * engine ({@see \Carticy\Conversions\Events\OrderEventService}): prefer
	 * WooCommerce's own `is_paid()` (which honors custom paid statuses via
	 * `wc_get_is_paid_statuses()`), then fall back to membership.
	 *
	 * @param object $order WooCommerce order.
	 * @return bool
	 */
	private function is_paid( object $order ): bool {
		if ( method_exists( $order, 'is_paid' ) ) {
			return (bool) $order->is_paid();
		}

		if ( method_exists( $order, 'get_status' ) ) {
			return in_array( (string) $order->get_status(), $this->paid_statuses(), true );
		}

		return true;
	}

	/**
	 * The canonical paid status slugs (filterable in WooCommerce).
	 *
	 * @return array<int, string>
	 */
	private function paid_statuses(): array {
		if ( function_exists( 'wc_get_is_paid_statuses' ) ) {
			$statuses = wc_get_is_paid_statuses();
			if ( is_array( $statuses ) && array() !== $statuses ) {
				return array_values( array_map( 'strval', $statuses ) );
			}
		}

		return self::FALLBACK_PAID_STATUSES;
	}

	/**
	 * Whether the order was paid within the window.
	 *
	 * @param object $order WooCommerce order.
	 * @param int    $cutoff Unix cutoff timestamp.
	 * @return bool
	 */
	private function within_window( object $order, int $cutoff ): bool {
		if ( ! method_exists( $order, 'get_date_paid' ) ) {
			return true;
		}

		$paid = $order->get_date_paid();
		if ( is_object( $paid ) && method_exists( $paid, 'getTimestamp' ) ) {
			return (int) $paid->getTimestamp() >= $cutoff;
		}

		return true;
	}

	/**
	 * Accumulate one order into its gateway bucket.
	 *
	 * @param array<string, array<string, mixed>> $gateways Gateway buckets (by reference).
	 * @param object                              $order WooCommerce order.
	 */
	private function accumulate( array &$gateways, object $order ): void {
		$method = method_exists( $order, 'get_payment_method' ) ? sanitize_key( (string) $order->get_payment_method() ) : '';
		if ( '' === $method ) {
			$method = 'unknown';
		}

		$title = method_exists( $order, 'get_payment_method_title' ) ? (string) $order->get_payment_method_title() : '';
		if ( '' === trim( $title ) ) {
			$title = $method;
		}

		if ( ! isset( $gateways[ $method ] ) ) {
			$gateways[ $method ] = array(
				'gateway'         => $method,
				'title'           => $title,
				'paid'            => 0,
				'tracked'         => 0,
				'gap_order_ids'   => array(),
				'gap_order_links' => array(),
			);
		}

		++$gateways[ $method ]['paid'];
		if ( $this->is_tracked( $order ) ) {
			++$gateways[ $method ]['tracked'];
		} elseif ( count( $gateways[ $method ]['gap_order_ids'] ) < self::MAX_GAP_ORDER_IDS && method_exists( $order, 'get_id' ) ) {
			$gateways[ $method ]['gap_order_ids'][]   = (int) $order->get_id();
			$gateways[ $method ]['gap_order_links'][] = array(
				'id'  => (int) $order->get_id(),
				'url' => method_exists( $order, 'get_edit_order_url' ) ? (string) $order->get_edit_order_url() : '',
			);
		}
	}

	/**
	 * Whether the order recorded a canonical purchase event.
	 *
	 * @param object $order WooCommerce order.
	 * @return bool
	 */
	private function is_tracked( object $order ): bool {
		foreach ( $this->store->get_events( $order ) as $event ) {
			if ( ConversionEventType::PURCHASE === $event->event_type() ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Finalize totals and per-gateway rows.
	 *
	 * @param array<string, array<string, mixed>> $gateways Gateway buckets.
	 * @param int                                 $window_days Window in days.
	 * @param int                                 $scanned Orders scanned.
	 * @param bool                                $capped Whether the page cap was hit.
	 * @param string                              $since GMT lower bound actually scanned.
	 * @param bool                                $clamped Whether the window was clamped to plugin activation.
	 * @return array<string, mixed>
	 */
	private function finalize( array $gateways, int $window_days, int $scanned, bool $capped, string $since = '', bool $clamped = false ): array {
		$paid_total    = 0;
		$tracked_total = 0;
		$rows          = array();

		foreach ( $gateways as $bucket ) {
			$paid           = (int) $bucket['paid'];
			$tracked        = (int) $bucket['tracked'];
			$gap            = max( 0, $paid - $tracked );
			$paid_total    += $paid;
			$tracked_total += $tracked;

			$rows[] = array(
				'gateway'       => (string) $bucket['gateway'],
				'title'         => (string) $bucket['title'],
				'paidOrders'    => $paid,
				'trackedOrders' => $tracked,
				'gapOrders'     => $gap,
				'gapOrderIds'   => array_map( 'intval', (array) ( $bucket['gap_order_ids'] ?? array() ) ),
				'gapOrderLinks' => array_values( (array) ( $bucket['gap_order_links'] ?? array() ) ),
				'accuracyPct'   => $this->percent( $tracked, $paid ),
				'diagnosis'     => $gap > 0 ? 'untracked_paid_orders' : '',
			);
		}

		return array(
			'window'    => array(
				'days'                => $window_days,
				'since'               => $since,
				'clampedToActivation' => $clamped,
			),
			'totals'    => array(
				'paidOrders'    => $paid_total,
				'trackedOrders' => $tracked_total,
				'gapOrders'     => max( 0, $paid_total - $tracked_total ),
				'accuracyPct'   => $this->percent( $tracked_total, $paid_total ),
			),
			'byGateway' => $rows,
			'scanned'   => $scanned,
			'capped'    => $capped,
		);
	}

	/**
	 * Compute a rounded accuracy percentage.
	 *
	 * @param int $tracked Tracked count.
	 * @param int $paid Paid count.
	 * @return float
	 */
	private function percent( int $tracked, int $paid ): float {
		if ( $paid <= 0 ) {
			return 100.0;
		}

		return round( ( $tracked / $paid ) * 100, 1 );
	}

	/**
	 * Empty report when WooCommerce is unavailable.
	 *
	 * @param int $window_days Window in days.
	 * @return array<string, mixed>
	 */
	private function empty_report( int $window_days ): array {
		return array(
			'window'    => array(
				'days'                => $window_days,
				'since'               => '',
				'clampedToActivation' => false,
			),
			'totals'    => array(
				'paidOrders'    => 0,
				'trackedOrders' => 0,
				'gapOrders'     => 0,
				'accuracyPct'   => 100.0,
			),
			'byGateway' => array(),
			'scanned'   => 0,
			'capped'    => false,
		);
	}
}
