<?php
/**
 * PS_Label_Debug — central structured logger for the label+crypto flow.
 *
 * Every event in the label pipeline lands in three sinks so we can debug
 * from whatever angle is handy:
 *
 *   1. WC logger (wp-content/uploads/wc-logs/ps-label-<date>-<hash>.log)
 *      — long-term file log for post-mortem analysis.
 *   2. Order note (per-order, visible under Orders in wp-admin)
 *      — merchant sees what happened on their order without SFTP access.
 *   3. Order meta `_ps_label_debug_log` (array, ring-buffer of last 80)
 *      — survives pagination/order-note cleanup; queried by AJAX for F12.
 *   4. Transient `ps_label_debug_recent` (last 40 global across orders)
 *      — powers the admin debug page and diagnostic bundles.
 *
 * On AJAX responses we include `_debug` so the browser can console.log()
 * it into F12 dev tools — no need to refresh and re-read order meta.
 *
 * Enable/disable with the PARCSELECT_LABEL_DEBUG constant (default ON during the
 * current rollout phase) or ps_label_debug_enabled option.
 *
 * @package ParcelSelector
 */

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

class Parcselect_Label_Debug {

	const META_KEY        = '_ps_label_debug_log';
	const TRANSIENT_KEY   = 'parcselect_label_debug_recent';
	const META_MAX        = 30; // ring-buffer size — trimmed at every persist (was 80, reduced to keep _ps_label_debug_log meta_value below ~10 KB per order).
	const TRANSIENT_MAX   = 40;
	const TRANSIENT_TTL   = 86400; // 24h

	/**
	 * Whether logging is currently active. Controlled via:
	 *   define( 'PARCSELECT_LABEL_DEBUG', false );   — hard off
	 *   define( 'PARCSELECT_LABEL_DEBUG', true );    — hard on
	 *   get_option( 'parcselect_label_debug_enabled' ) — runtime toggle (default '1')
	 */
	public static function enabled() {
		if ( defined( 'PARCSELECT_LABEL_DEBUG' ) ) {
			return (bool) PARCSELECT_LABEL_DEBUG;
		}
		// Default OFF in production — prevents sensitive data (webhook payloads,
		// tracking numbers, session tokens) from being logged without admin consent.
		return '1' === (string) get_option( 'parcselect_label_debug_enabled', '0' );
	}

	/**
	 * Log a single structured event.
	 *
	 * Adds two extra diagnostic fields to every entry:
	 *   - request_id : 8-char correlator shared across one PHP request, so a full
	 *     flow (AJAX → VPS → webhook → mirror) can be stitched back together in F12.
	 *   - session_id : when known, the label session ID the event belongs to —
	 *     binds events of a single order flow across multiple requests.
	 *
	 * @param int|null $order_id  Order the event is about (null = not order-scoped).
	 * @param string   $event     Short event code, e.g. 'session.create.ok'.
	 * @param array    $data      Extra structured payload.
	 * @param string   $level     'info' | 'warning' | 'error'.
	 */
	public static function log( $order_id, $event, array $data = array(), $level = 'info' ) {
		if ( ! self::enabled() ) {
			return;
		}
		// NOTE: WP's sanitize_key() strips dots, which would collapse 'session.status.format.ok'
		// into 'sessionstatusformatok' and break every downstream mapping
		// (accordion admin_events array, F12 filter, 'format.mismatch' JS banner).
		// Use a purpose-built sanitizer that keeps dots/underscores/hyphens only.
		$safe_event = strtolower( preg_replace( '/[^a-z0-9._-]/i', '', (string) $event ) );
		$entry = array(
			'ts'         => gmdate( 'c' ),
			'order_id'   => $order_id ? (int) $order_id : null,
			'event'      => $safe_event,
			'level'      => in_array( $level, array( 'info', 'warning', 'error' ), true ) ? $level : 'info',
			'request_id' => self::request_id(),
			'session_id' => self::session_id_for( $order_id ),
			'data'       => self::sanitize_data( $data ),
		);

		// Sink 1: WC logger.
		self::write_wc_log( $entry );

		// Sink 2+3: per-order (note + meta ring-buffer).
		if ( $order_id ) {
			self::write_order_sinks( $order_id, $entry );
		}

		// Sink 4: global recent.
		self::write_transient( $entry );
	}

	/**
	 * Pull the order's session_id so it rides every log entry automatically.
	 * Safe on missing order — returns ''.
	 *
	 * Per-request static cache: a single render of the metabox accordion can
	 * fire 80+ log() calls (one per existing trail entry). Without caching
	 * we'd query wc_get_order() 80 times — N+1 query storm. The static cache
	 * scopes to the current PHP request, so memory safe and correct.
	 */
	protected static function session_id_for( $order_id ) {
		static $cache = array();
		if ( ! $order_id ) {
			return '';
		}
		$key = (int) $order_id;
		if ( array_key_exists( $key, $cache ) ) {
			return $cache[ $key ];
		}
		$order = wc_get_order( $key );
		if ( ! $order ) {
			$cache[ $key ] = '';
			return '';
		}
		$sid = (string) $order->get_meta( '_ps_label_session_id', true );
		$cache[ $key ] = $sid ? substr( $sid, 0, 32 ) : '';
		return $cache[ $key ];
	}

	/**
	 * Validate that a response body contains the expected keys, logging
	 * `<prefix>.format.ok` on full match or `<prefix>.format.mismatch` with a
	 * diff otherwise. Returns true on match, false on mismatch.
	 *
	 * Use on every external HTTP response so a silent schema drift (VPS renames
	 * 'session_id' → 'sid', or shipping provider changes 'tracking_number' → 'tracking')
	 * surfaces loudly in the trail instead of passing through as "works fine".
	 *
	 * @param int|null $order_id     Order scope.
	 * @param string   $prefix       Namespace for the event code (e.g. 'session.create').
	 * @param mixed    $response     Decoded response (array on success).
	 * @param array    $required     Keys that MUST be present and non-empty.
	 * @param array    $optional     Keys that MAY be present (informational only).
	 * @param array    $extra_ctx    Additional context to include in the log.
	 */
	public static function validate_format( $order_id, $prefix, $response, array $required, array $optional = array(), array $extra_ctx = array() ) {
		if ( ! self::enabled() ) {
			return is_array( $response );
		}
		if ( ! is_array( $response ) ) {
			self::warn( $order_id, $prefix . '.format.mismatch', array_merge( $extra_ctx, array(
				'reason'      => 'non_array_response',
				'actual_type' => gettype( $response ),
			) ) );
			return false;
		}
		$missing    = array();
		$empty      = array();
		$present    = array();
		foreach ( $required as $k ) {
			if ( ! array_key_exists( $k, $response ) ) {
				$missing[] = $k;
			} elseif ( null === $response[ $k ] || '' === $response[ $k ] || ( is_array( $response[ $k ] ) && empty( $response[ $k ] ) ) ) {
				$empty[] = $k;
			} else {
				$present[] = $k;
			}
		}
		$extra_keys = array_values( array_diff( array_keys( $response ), array_merge( $required, $optional ) ) );
		$ctx        = array_merge( $extra_ctx, array(
			'required_present' => $present,
			'required_missing' => $missing,
			'required_empty'   => $empty,
			'optional_seen'    => array_values( array_intersect( $optional, array_keys( $response ) ) ),
			'extra_keys'       => array_slice( $extra_keys, 0, 12 ),
		) );
		if ( empty( $missing ) && empty( $empty ) ) {
			self::info( $order_id, $prefix . '.format.ok', $ctx );
			return true;
		}
		self::warn( $order_id, $prefix . '.format.mismatch', $ctx );
		return false;
	}

	/**
	 * Log the raw HTTP metadata of a wp_remote_* response with no transformation.
	 * Pair it with `validate_format()` after to check the decoded body.
	 * Never dumps the whole body — trimmed to first 400 chars for forensics.
	 */
	public static function log_http( $order_id, $event, $response, array $extra_ctx = array() ) {
		if ( ! self::enabled() ) {
			return;
		}
		if ( is_wp_error( $response ) ) {
			self::error( $order_id, $event . '.network_error', array_merge( $extra_ctx, array(
				'code'    => $response->get_error_code(),
				'message' => $response->get_error_message(),
			) ) );
			return;
		}
		$code    = (int) wp_remote_retrieve_response_code( $response );
		$headers = wp_remote_retrieve_headers( $response );
		$body    = (string) wp_remote_retrieve_body( $response );
		$hdrs    = array();
		if ( $headers && is_object( $headers ) && method_exists( $headers, 'getAll' ) ) {
			$all = $headers->getAll();
			foreach ( array( 'content-type', 'content-length', 'x-ps-label-source', 'x-ps-request-id', 'server', 'date' ) as $k ) {
				if ( isset( $all[ $k ] ) ) {
					$hdrs[ $k ] = is_array( $all[ $k ] ) ? reset( $all[ $k ] ) : $all[ $k ];
				}
			}
		}
		$ctx = array_merge( $extra_ctx, array(
			'http_code'  => $code,
			'bytes'      => strlen( $body ),
			'body_head'  => substr( $body, 0, 400 ),
			'headers'    => $hdrs,
		) );
		$level = ( $code >= 200 && $code < 400 ) ? 'info' : ( $code >= 500 ? 'error' : 'warning' );
		self::log( $order_id, $event, $ctx, $level );
	}

	public static function info( $order_id, $event, array $data = array() ) {
		self::log( $order_id, $event, $data, 'info' );
	}
	public static function warn( $order_id, $event, array $data = array() ) {
		self::log( $order_id, $event, $data, 'warning' );
	}
	public static function error( $order_id, $event, array $data = array() ) {
		self::log( $order_id, $event, $data, 'error' );
	}

	/**
	 * Build the `_debug` payload to attach to an AJAX response. JS side
	 * console.groups and prints each entry.
	 *
	 * @param int   $order_id  Pull debug trail for this order.
	 * @param array $additional Fresh entries to append in-memory (not persisted).
	 * @return array Structure: { trail: [..], latest: [..], enabled: bool }
	 */
	public static function for_response( $order_id = 0, array $additional = array() ) {
		$trail = array();
		if ( $order_id && ( $order = wc_get_order( $order_id ) ) ) {
			$stored = $order->get_meta( self::META_KEY, true );
			if ( is_array( $stored ) ) {
				$trail = $stored;
			}
		}
		return array(
			'enabled'    => self::enabled(),
			'trail'      => $trail,
			'appended'   => $additional,
			'request_id' => self::request_id(),
		);
	}

	/**
	 * Short id shared across all events emitted during the same PHP request.
	 * Lets us correlate JS console logs with server-side trail entries.
	 */
	public static function request_id() {
		static $rid = null;
		if ( null === $rid ) {
			$rid = substr( md5( microtime( true ) . wp_rand() ), 0, 8 );
		}
		return $rid;
	}

	// ---------- sinks ----------

	protected static function write_wc_log( $entry ) {
		if ( ! function_exists( 'wc_get_logger' ) ) {
			return;
		}
		$logger = wc_get_logger();
		$msg    = sprintf(
			'[%s] order=%s %s %s',
			$entry['level'],
			$entry['order_id'] ?? '-',
			$entry['event'],
			wp_json_encode( $entry['data'], JSON_UNESCAPED_SLASHES )
		);
		$logger->log( $entry['level'], $msg, array( 'source' => 'ps-label' ) );
	}

	/** Events worth a visible order note (not every debug line). */
	protected static $note_events = array(
		'session.create.ok',
		'session.create.http_error',
		'session.create.network_error',
		'webhook.received',
		'webhook.order_updated',
		'webhook.auth_failed',
		'autotrigger.fired',
		'autotrigger.create_failed',
		'autotrigger.no_session_id',
		'enroll.ok',
		'enroll.http_error',
		'enroll.network_error',
	);

	protected static function write_order_sinks( $order_id, $entry ) {
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}
		// Ring-buffer meta — always, every event.
		$existing = $order->get_meta( self::META_KEY, true );
		if ( ! is_array( $existing ) ) {
			$existing = array();
		}
		$existing[] = $entry;
		if ( count( $existing ) > self::META_MAX ) {
			$existing = array_slice( $existing, -self::META_MAX );
		}
		$order->update_meta_data( self::META_KEY, $existing );

		// Order note only for important events or any warning/error —
		// otherwise the notes tab fills up with boilerplate and pushes real ones
		// off the first page.
		$should_note = ( 'info' !== $entry['level'] )
			|| in_array( $entry['event'], self::$note_events, true );

		if ( $should_note ) {
			$icon = 'info' === $entry['level'] ? '🔵' : ( 'warning' === $entry['level'] ? '🟠' : '🔴' );
			$short = sprintf(
				'%s PS-LABEL %s · %s · rid=%s',
				$icon,
				$entry['event'],
				self::compact_data( $entry['data'] ),
				self::request_id()
			);
			$order->add_order_note( $short );
		}
		$order->save();
	}

	protected static function write_transient( $entry ) {
		$recent = get_transient( self::TRANSIENT_KEY );
		if ( ! is_array( $recent ) ) {
			$recent = array();
		}
		$recent[] = $entry;
		if ( count( $recent ) > self::TRANSIENT_MAX ) {
			$recent = array_slice( $recent, -self::TRANSIENT_MAX );
		}
		set_transient( self::TRANSIENT_KEY, $recent, self::TRANSIENT_TTL );
	}

	// ---------- helpers ----------

	/**
	 * Strip known sensitive fields from any structured data before it lands
	 * in a log. Ambiguous: we redact the SECRET and TOKEN but keep IDs and
	 * counts — readable trail, no leakage.
	 */
	protected static function sanitize_data( array $data ) {
		$out = array();
		foreach ( $data as $k => $v ) {
			$lk = strtolower( (string) $k );
			if ( preg_match( '/secret|token|password|authorization|signature|api_secret/', $lk ) ) {
				$out[ $k ] = self::mask_secret( (string) $v );
			} elseif ( is_array( $v ) ) {
				$out[ $k ] = self::sanitize_data( $v );
			} elseif ( is_scalar( $v ) ) {
				$out[ $k ] = $v;
			} else {
				$out[ $k ] = gettype( $v );
			}
		}
		return $out;
	}

	protected static function mask_secret( $s ) {
		if ( empty( $s ) ) {
			return '<empty>';
		}
		$len = strlen( $s );
		if ( $len <= 6 ) {
			return str_repeat( '*', $len );
		}
		return substr( $s, 0, 3 ) . '…' . substr( $s, -2 ) . '(len=' . $len . ')';
	}

	protected static function compact_data( $data ) {
		if ( empty( $data ) ) {
			return '';
		}
		$pairs = array();
		foreach ( $data as $k => $v ) {
			if ( is_scalar( $v ) ) {
				$pairs[] = $k . '=' . substr( (string) $v, 0, 60 );
			} elseif ( is_array( $v ) ) {
				$pairs[] = $k . '=' . self::compact_data( $v );
			}
			if ( count( $pairs ) >= 6 ) {
				$pairs[] = '…';
				break;
			}
		}
		return implode( ' ', $pairs );
	}

	/**
	 * AJAX endpoint: get debug trail for an order (for JS F12 viewer refresh).
	 */
	public static function ajax_get_trail() {
		check_ajax_referer( 'parcselect_label_nonce', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'error' => 'unauthorized' ) );
		}
		$order_id = absint( $_POST['order_id'] ?? 0 );
		wp_send_json_success( self::for_response( $order_id ) );
	}

	/**
	 * Register hooks. Call once from PS_LABEL init.php.
	 */
	public static function init() {
		add_action( 'wp_ajax_parcselect_label_debug_trail', array( __CLASS__, 'ajax_get_trail' ) );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_viewer' ) );
		add_action( 'admin_notices', array( __CLASS__, 'render_format_mismatch_notice' ) );
	}

	/**
	 * Surface format-mismatch + drift events from the last hour as a dismissable
	 * admin notice. Otherwise the only place to see them is the order accordion
	 * or F12 — and most admins won't dig that deep until something visibly breaks.
	 */
	public static function render_format_mismatch_notice() {
		if ( ! current_user_can( 'manage_options' ) ) return;
		$recent = get_transient( self::TRANSIENT_KEY );
		if ( ! is_array( $recent ) || empty( $recent ) ) return;
		$cutoff = time() - HOUR_IN_SECONDS;
		$alerts = array();
		foreach ( $recent as $e ) {
			if ( ! is_array( $e ) || empty( $e['ts'] ) || empty( $e['event'] ) ) continue;
			if ( strtotime( $e['ts'] ) < $cutoff ) continue;
			if ( strpos( $e['event'], 'format.mismatch' ) === false &&
				 strpos( $e['event'], 'drift_detected' ) === false &&
				 strpos( $e['event'], 'webhook.auth_failed' ) === false ) continue;
			$alerts[] = $e;
		}
		if ( empty( $alerts ) ) return;
		echo '<div class="notice notice-warning is-dismissible"><p><strong>'
			. esc_html__( 'Label · ', 'parcel-selector' )
			. (int) count( $alerts ) . esc_html__( ' warning(s) in the last hour', 'parcel-selector' ) . '</strong> '
			. esc_html__( '— Open any order and expand Label history for diagnostics.', 'parcel-selector' )
			. '</p></div>';
	}

	/**
	 * Inject the browser-side debug viewer on WC order edit screens.
	 */
	public static function enqueue_viewer( $hook ) {
		if ( ! self::enabled() ) {
			return;
		}
		// Order edit page — legacy posts and HPOS custom order tables.
		// Intentionally not reading $_POST/writing state, so nonce check isn't
		// needed here; this is a read-only query check for which admin screen we're on.
		$is_order_screen = false;
		$order_id        = 0;
		if ( 'post.php' === $hook && isset( $_GET['post'] ) && 'shop_order' === get_post_type( absint( $_GET['post'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$is_order_screen = true;
			$order_id        = absint( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		} elseif ( 'woocommerce_page_wc-orders' === $hook && isset( $_GET['id'] ) && isset( $_GET['action'] ) && 'edit' === $_GET['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$is_order_screen = true;
			$order_id        = absint( $_GET['id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		}
		if ( ! $is_order_screen || empty( $order_id ) ) {
			return;
		}
		wp_enqueue_script(
			'parcselect-label-debug',
			PARCSELECT_LABEL_URL . 'assets/js/ps-label-debug.js',
			array(),
			defined( 'PARCSELECT_VERSION' ) ? PARCSELECT_VERSION . '.' . time() : time(),
			true
		);
		wp_localize_script( 'parcselect-label-debug', 'ParcselectLabelDebug', array(
			'ajax_url' => admin_url( 'admin-ajax.php' ),
			'nonce'    => wp_create_nonce( 'parcselect_label_nonce' ),
			'order_id' => $order_id,
			'poll_ms'  => 5000,
		) );
	}
}
