<?php
/**
 * Split Pay Diagnostic Snapshot
 *
 * NOTE: this file is a STANDALONE mu-plugin shipped inside the main Split Pay
 * release zip. The conventional "Plugin Name:" / "Version:" header has been
 * intentionally REMOVED from this docblock — when WordPress unzips the main
 * plugin and scans for plugin files, a second `Plugin Name:` header at
 * `tools/spp-diagnostic-snapshot.php` collides with the main plugin file and
 * sorts ahead of it alphabetically ("Diagnostic" < "for"). The post-install
 * "Activate Plugin" link in the upgrader skin then points at this file's path
 * which `validate_plugin()` cannot resolve (it's two levels deep, not one),
 * surfacing "The plugin does not have a valid header." See 3.7.5 BUG 4.
 * Customers drop this file into wp-content/mu-plugins/ where WP loads it
 * directly without scanning for the header anyway, so no header is needed.
 *
 * URI:         https://splitpayplugin.com/
 * Description: Captures a full diagnostic JSON snapshot every time Split Pay attempts a transfer. Used by Gaucho Plugins support to debug edge cases. Install in `wp-content/mu-plugins/`, reproduce the issue, then zip and send the `wp-content/uploads/spp-diagnostics/` folder back to support.
 * Tool Version: 1.0.0
 * Author:       Gaucho Plugins
 * Author URI:   https://gauchoplugins.com/
 * License:      GPL-3.0+
 * Requires at least: 5.6
 * Requires PHP: 7.4
 *
 * @package SplitPay
 *
 * How to use (customer-facing instructions):
 *   1. Download this file: spp-diagnostic-snapshot.php
 *   2. Upload to wp-content/mu-plugins/ via SFTP/cPanel (create the folder if it doesn't exist).
 *   3. Reproduce the Split Pay issue exactly as before (place orders that show the problem).
 *   4. Zip the folder `wp-content/uploads/spp-diagnostics/` and email it to support@gauchoplugins.com.
 *   5. Once support confirms they have what they need, delete this file from mu-plugins.
 *
 * Privacy: snapshots include order IDs, totals, plugin/theme list, and Stripe transfer/charge
 * IDs. NO card numbers, NO emails, NO customer addresses are written. Files auto-delete
 * after 14 days. Snapshots are written only when a Split Pay transfer attempt runs.
 */

declare( strict_types=1 );

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

if ( class_exists( 'SPP_Diagnostic_Snapshot' ) ) {
	return;
}

final class SPP_Diagnostic_Snapshot {

	private const SNAPSHOT_TTL_SECONDS = 14 * DAY_IN_SECONDS;
	private const MAX_SNAPSHOTS        = 100;

	public static function bootstrap(): void {
		// Lazy bind to all known transfer-attempt entry points.
		add_action( 'spp_before_transfers',         [ self::class, 'captureBefore' ], 1, 2 );
		add_action( 'spp_after_transfers',          [ self::class, 'captureAfter' ],  99, 3 );
		add_action( 'spp_transfer_error',           [ self::class, 'captureError' ],  10, 3 );

		// Daily cleanup.
		if ( ! wp_next_scheduled( 'spp_diag_cleanup' ) ) {
			wp_schedule_event( time() + 3600, 'daily', 'spp_diag_cleanup' );
		}
		add_action( 'spp_diag_cleanup', [ self::class, 'cleanup' ] );
	}

	/**
	 * Fires before TransferEngine attempts transfers for an order.
	 * @param int|string $orderId
	 * @param array<string,mixed> $context  raw context the engine passes; format is engine-internal.
	 */
	public static function captureBefore( $orderId, array $context = [] ): void {
		self::writeSnapshot( $orderId, 'before', $context );
	}

	/**
	 * Fires after all transfer attempts for an order have completed (success or fail).
	 * @param int|string $orderId
	 * @param array<int,array<string,mixed>> $results
	 * @param array<string,mixed> $context
	 */
	public static function captureAfter( $orderId, array $results = [], array $context = [] ): void {
		self::writeSnapshot( $orderId, 'after', [
			'results' => $results,
			'context' => $context,
		] );
	}

	/**
	 * Fires on individual transfer failure.
	 * @param int|string $orderId
	 * @param string $message
	 * @param array<string,mixed> $details
	 */
	public static function captureError( $orderId, string $message = '', array $details = [] ): void {
		self::writeSnapshot( $orderId, 'error', [
			'message' => $message,
			'details' => $details,
		] );
	}

	private static function writeSnapshot( $orderId, string $stage, array $payload ): void {
		try {
			$dir = self::dir();
			if ( ! $dir ) {
				return;
			}

			$snapshot = [
				'meta'   => self::meta(),
				'order'  => self::orderSnapshot( $orderId ),
				'plugin' => self::pluginSnapshot(),
				'stripe' => self::stripeSnapshot( $orderId ),
				'stage'  => $stage,
				'payload' => self::sanitizePayload( $payload ),
				'taken_at' => gmdate( 'c' ),
			];

			$file = sprintf(
				'%s/%s-%s-%s.json',
				$dir,
				gmdate( 'Ymd-His' ),
				preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $orderId ),
				$stage
			);

			file_put_contents( $file, wp_json_encode( $snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
		} catch ( \Throwable $e ) {
			// Diagnostic capture itself must never break the transfer.
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[SPP Diag] capture failed: ' . $e->getMessage() ); } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}
	}

	private static function meta(): array {
		global $wp_version;
		return [
			'plugin_version' => defined( 'SPP_PLUGIN_VER' ) ? SPP_PLUGIN_VER : 'unknown',
			'wp_version'     => $wp_version ?? '?',
			'wc_version'     => defined( 'WC_VERSION' ) ? WC_VERSION : ( class_exists( 'WooCommerce' ) ? ( WC()->version ?? '?' ) : 'WC-not-loaded' ),
			'php_version'    => PHP_VERSION,
			'locale'         => function_exists( 'get_locale' ) ? get_locale() : '?',
			'timestamp'      => gmdate( 'c' ),
			'hpos_enabled'   => self::hposEnabled(),
		];
	}

	private static function hposEnabled(): bool {
		if ( ! class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) ) {
			return false;
		}
		return \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
	}

	private static function orderSnapshot( $orderId ): array {
		if ( ! function_exists( 'wc_get_order' ) ) {
			return [ 'error' => 'wc_get_order unavailable' ];
		}
		$order = wc_get_order( $orderId );
		if ( ! $order ) {
			return [ 'id' => $orderId, 'error' => 'order not found' ];
		}

		$items = [];
		foreach ( $order->get_items( 'line_item' ) as $itemId => $item ) {
			$items[] = [
				'item_id'      => $itemId,
				'product_id'   => $item->get_product_id(),
				'variation_id' => $item->get_variation_id(),
				'name'         => $item->get_name(),
				'qty'          => $item->get_quantity(),
				'total'        => (float) $item->get_total(),
				'subtotal'     => (float) $item->get_subtotal(),
				'_bsd_split_pay_meta' => self::lineItemSplitPayMeta( $item ),
			];
		}

		$shipping = [];
		foreach ( $order->get_items( 'shipping' ) as $itemId => $item ) {
			$shipping[] = [
				'item_id' => $itemId,
				'method'  => $item->get_method_title(),
				'total'   => (float) $item->get_total(),
			];
		}

		return [
			'id'             => $order->get_id(),
			'status'         => $order->get_status(),
			'payment_method' => $order->get_payment_method(),
			'currency'       => $order->get_currency(),
			'total'          => (float) $order->get_total(),
			'subtotal'       => (float) $order->get_subtotal(),
			'shipping_total' => (float) $order->get_shipping_total(),
			'total_tax'      => (float) $order->get_total_tax(),
			'transaction_id' => $order->get_transaction_id(),
			'item_count'     => count( $items ),
			'items'          => $items,
			'shipping'       => $shipping,
			'split_pay_meta' => self::orderSplitPayMeta( $order ),
		];
	}

	private static function lineItemSplitPayMeta( $item ): array {
		$out = [];
		foreach ( $item->get_meta_data() as $m ) {
			$key = $m->key ?? '';
			if ( strpos( $key, '_bsd_' ) === 0 || strpos( $key, '_spp_' ) === 0 || strpos( $key, '_stripe_connect_split_pay' ) === 0 ) {
				$out[ $key ] = self::scrub( $m->value );
			}
		}
		return $out;
	}

	private static function orderSplitPayMeta( $order ): array {
		$keys = [
			'_spp_pending_delayed_transfer',
			'_bsd_is_transfer_process_done',
			'_bsd_transfer_status',
			'_bsd_failed_transfers',
			'_bsd_log_transfer',
		];
		$out = [];
		foreach ( $keys as $k ) {
			$v = $order->get_meta( $k );
			if ( $v !== '' && $v !== null && $v !== false ) {
				$out[ $k ] = self::scrub( $v );
			}
		}
		return $out;
	}

	private static function pluginSnapshot(): array {
		$active = function_exists( 'get_option' ) ? (array) get_option( 'active_plugins', [] ) : [];
		return [
			'active_plugins' => $active,
			'theme'          => function_exists( 'get_template' ) ? get_template() : '?',
			'gauchoplugin_settings' => [
				'spp_delayed_transfers_enabled' => function_exists( 'get_option' ) ? get_option( 'spp_delayed_transfers_enabled', null ) : null,
				'bsd_test_mode'                 => function_exists( 'get_option' ) ? get_option( 'bsd_test_mode', null ) : null,
			],
		];
	}

	private static function stripeSnapshot( $orderId ): array {
		if ( ! function_exists( 'wc_get_order' ) ) {
			return [];
		}
		$order = wc_get_order( $orderId );
		if ( ! $order ) {
			return [];
		}
		return [
			'order_transaction_id' => $order->get_transaction_id(),
			'order_currency'       => $order->get_currency(),
			// Intentionally NOT reaching out to Stripe API from inside this mu-plugin.
			// The transfer attempt itself logs Stripe responses via WC_Stripe_Logger;
			// support will correlate by transaction_id.
		];
	}

	/**
	 * Best-effort PII scrubbing — keep IDs, drop addresses/emails/cards.
	 */
	private static function sanitizePayload( $value ) {
		return self::scrub( $value );
	}

	private static function scrub( $value, int $depth = 0 ) {
		if ( $depth > 6 ) {
			return '[truncated]';
		}
		if ( is_object( $value ) ) {
			$value = (array) $value;
		}
		if ( is_array( $value ) ) {
			$out = [];
			foreach ( $value as $k => $v ) {
				$kLow = strtolower( (string) $k );
				if ( preg_match( '/(email|phone|cardholder|first_name|last_name|address|postcode|postal|country|state|city|number|cvc|cvv|line_1|line1)/', $kLow ) ) {
					$out[ $k ] = '[redacted]';
				} else {
					$out[ $k ] = self::scrub( $v, $depth + 1 );
				}
			}
			return $out;
		}
		if ( is_string( $value ) && strlen( $value ) > 2000 ) {
			return substr( $value, 0, 2000 ) . '…[truncated]';
		}
		return $value;
	}

	private static function dir(): ?string {
		if ( ! function_exists( 'wp_upload_dir' ) ) {
			return null;
		}
		$ud  = wp_upload_dir();
		$dir = ( $ud['basedir'] ?? sys_get_temp_dir() ) . '/spp-diagnostics';
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
			file_put_contents( $dir . '/.htaccess', "Order allow,deny\nDeny from all\n" );
			file_put_contents( $dir . '/index.php', "<?php // silence" );
		}
		// Lightweight writability probe on a directory this tool just created under uploads; WP_Filesystem would require credential init and add failure modes for a simple diagnostic check.
		return is_writable( $dir ) ? $dir : null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- simple writability probe on own uploads subdir; WP_Filesystem init unwarranted here.
	}

	public static function cleanup(): void {
		$dir = self::dir();
		if ( ! $dir || ! is_dir( $dir ) ) {
			return;
		}
		$files = glob( $dir . '/*.json' ) ?: [];

		// TTL cleanup.
		$cutoff = time() - self::SNAPSHOT_TTL_SECONDS;
		foreach ( $files as $f ) {
			if ( @filemtime( $f ) < $cutoff ) {
				wp_delete_file( $f );
			}
		}

		// Cap count cleanup (newest first).
		$files = glob( $dir . '/*.json' ) ?: [];
		if ( count( $files ) > self::MAX_SNAPSHOTS ) {
			usort( $files, fn( $a, $b ) => filemtime( $a ) - filemtime( $b ) );
			$excess = array_slice( $files, 0, count( $files ) - self::MAX_SNAPSHOTS );
			foreach ( $excess as $f ) {
				wp_delete_file( $f );
			}
		}
	}
}

SPP_Diagnostic_Snapshot::bootstrap();
