<?php
/**
 * PS_Label_Session_Client — thin client for map-server /embed/label/*.
 *
 * VPS map-server already implements the full crypto→label pipeline:
 *   POST /embed/label/session   — create session, returns crypto address + amount + label_session_id
 *   GET  /embed/label/status/:id — poll status
 *   GET  /embed/label/download/:id — download completed PDF
 *   POST /embed/label/confirm    — called by x21 daemon when on-chain payment detected
 *
 * FREE plugin is a pure consumer — never touches shipping provider, never verifies blockchain.
 *
 * @package ParcelSelector
 */

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

class Parcselect_Label_Session_Client {

	/**
	 * Base URL for map-server. Defaults to public parcelselector.com; admin can override
	 * via 'parcselect_label_mapserver_url' option (e.g. direct VPS access during development).
	 */
	private static function base() {
		// Canonical host for the label pipeline is map.parcelselector.com.
		// The old default 'https://parcelselector.com' pointed at the MARKETING
		// site which returned 404 HTML pages for /embed/label/* paths — that
		// was the cause of the "HTTP 404: <html lang=pl>" errors observed in
		// the admin widget. Corrected here.
		$url = trim( (string) get_option( 'parcselect_label_mapserver_url', '' ) );
		if ( empty( $url ) ) {
			$url = trim( (string) get_option( 'parcselect_label_proxy_url', 'https://map.parcelselector.com' ) );
		}
		// Belt + braces: even if an old install has 'parcelselector.com' without
		// the map. subdomain stored as proxy_url, route to map. Doing it here
		// avoids needing a migration.
		if ( preg_match( '#^https?://(?:www\.)?parcelselector\.com/?$#i', $url ) ) {
			$url = 'https://map.parcelselector.com';
		}
		return rtrim( $url, '/' );
	}

	/**
	 * Build HMAC auth headers (same scheme as /labels/* endpoints).
	 */
	private static function auth_headers() {
		$installation = get_option( 'parcselect_installation_id', '' );
		$secret       = get_option( 'parcselect_label_proxy_secret', '' );
		$signature    = ( $installation && $secret ) ? hash_hmac( 'sha256', $installation, $secret ) : '';
		return array(
			'X-PS-Installation' => (string) $installation,
			'X-PS-Secret'       => $signature,
			'X-PS-Timestamp'    => (string) time(),
			'Content-Type'      => 'application/json',
			'Accept'            => 'application/json',
			'X-PS-Request-ID'   => self::request_id(),
		);
	}

	/**
	 * Consistent request id across a single PHP request so every VPS call
	 * it makes carries the same correlator — makes F12 + VPS logs match up.
	 */
	private static function request_id() {
		static $rid = null;
		if ( null === $rid ) {
			if ( ! empty( $_SERVER['HTTP_X_PS_REQUEST_ID'] ) ) {
				$rid = substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_PS_REQUEST_ID'] ) ), 0, 32 );
			} elseif ( class_exists( 'Parcselect_Label_Debug' ) && method_exists( 'Parcselect_Label_Debug', 'request_id' ) ) {
				$rid = Parcselect_Label_Debug::request_id();
			} else {
				$rid = 'php-' . substr( md5( microtime( true ) . wp_rand() ), 0, 8 );
			}
		}
		return $rid;
	}

	/**
	 * POST /embed/label/session — create payment+label session.
	 *
	 * @param array $payload order_id, carrier, locker_id, recv_*, snd_*, size, weight.
	 * @return array|WP_Error decoded JSON on success.
	 */
	public static function create_session( array $payload ) {
		if ( empty( $payload['callback_url'] ) ) {
			$payload['callback_url'] = rest_url( 'ps/v1/label-callback' );
		}
		$order_id = isset( $payload['order_id'] ) ? (int) $payload['order_id'] : 0;
		Parcselect_Label_Debug::info( $order_id, 'session.create.request', array(
			'endpoint'     => self::base() . '/embed/label/session',
			'carrier'      => $payload['carrier'] ?? '',
			'locker_id'    => $payload['locker_id'] ?? '',
			'size'         => $payload['size'] ?? '',
			'weight'       => $payload['weight'] ?? '',
			'callback_url' => $payload['callback_url'],
		) );

		// QW#4 — retry once on transient network error. Timeout 45s so slow
		// upstream rate APIs (CoinGecko/Binance) don't kill legit sessions.
		// QW#13 — sslverify filter so hosts with outdated CA bundles can
		// opt out as a workaround rather than losing label generation.
		$args = array(
			'timeout'   => 45,
			'headers'   => self::auth_headers(),
			'body'      => wp_json_encode( $payload ),
			'sslverify' => apply_filters( 'parcselect_label_sslverify', true ),
		);
		$t0       = microtime( true );
		$response = wp_remote_post( self::base() . '/embed/label/session', $args );
		if ( is_wp_error( $response ) ) {
			$code = $response->get_error_code();
			if ( in_array( $code, array( 'http_request_failed', 'http_request_timeout' ), true ) ) {
				Parcselect_Label_Debug::warn( $order_id, 'session.create.retry', array( 'reason' => $code ) );
				sleep( 2 );
				$t0       = microtime( true );
				$response = wp_remote_post( self::base() . '/embed/label/session', $args );
			}
		}
		$elapsed_ms = (int) ( ( microtime( true ) - $t0 ) * 1000 );
		// Log raw HTTP metadata (code, bytes, content-type, body head) — always,
		// before any parsing so we have evidence even if JSON decode fails.
		Parcselect_Label_Debug::log_http( $order_id, 'session.create.http', $response, array( 'elapsed_ms' => $elapsed_ms ) );
		if ( is_wp_error( $response ) ) {
			Parcselect_Label_Debug::error( $order_id, 'session.create.network_error', array(
				'code'    => $response->get_error_code(),
				'message' => $response->get_error_message(),
			) );
			return $response;
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( 200 !== $code || ! is_array( $body ) ) {
			Parcselect_Label_Debug::error( $order_id, 'session.create.http_error', array(
				'http'      => $code,
				'body_head' => substr( (string) wp_remote_retrieve_body( $response ), 0, 300 ),
			) );
			return new WP_Error(
				'session_create_failed',
				sprintf( 'map-server session HTTP %d: %s', $code, isset( $body['error'] ) ? $body['error'] : wp_remote_retrieve_body( $response ) )
			);
		}
		// Format validator — loudly complains if VPS response drops or renames
		// a key the plugin depends on.
		Parcselect_Label_Debug::validate_format(
			$order_id,
			'session.create',
			$body,
			array( 'session_id', 'price_pln', 'price_sol', 'sol_wallet' ),
			array( 'qr_uri', 'timeout', 'carrier', 'payload_echo' )
		);
		Parcselect_Label_Debug::info( $order_id, 'session.create.ok', array(
			'session_id' => $body['session_id'] ?? '',
			'price_pln'  => $body['price_pln'] ?? '',
			'price_sol'  => $body['price_sol'] ?? '',
			'sol_wallet' => isset( $body['sol_wallet'] ) ? substr( (string) $body['sol_wallet'], 0, 10 ) . '…' : '',
			'timeout'    => $body['timeout'] ?? '',
		) );
		return $body;
	}

	/**
	 * GET /embed/label/status/:id — poll status.
	 *
	 * @param string $session_id Session ID from create_session response.
	 * @return array|WP_Error {status, tracking_number?, label_url?, txid?}
	 */
	public static function get_status( $session_id, $order_id = 0 ) {
		$session_id = sanitize_text_field( $session_id );
		if ( empty( $session_id ) ) {
			return new WP_Error( 'missing_id', 'session_id required' );
		}
		Parcselect_Label_Debug::info( $order_id ?: null, 'session.status.request', array(
			'session_id' => substr( $session_id, 0, 12 ) . '…',
			'endpoint'   => self::base() . '/embed/label/status/<id>',
		) );
		$t0       = microtime( true );
		$response = wp_remote_get(
			self::base() . '/embed/label/status/' . rawurlencode( $session_id ),
			array(
				'timeout'   => 10,
				'headers'   => self::auth_headers(),
				'sslverify' => apply_filters( 'parcselect_label_sslverify', true ),
			)
		);
		$elapsed_ms = (int) ( ( microtime( true ) - $t0 ) * 1000 );
		Parcselect_Label_Debug::log_http( $order_id ?: null, 'session.status.http', $response, array( 'elapsed_ms' => $elapsed_ms ) );
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		// Status response schema depends on the session state — different keys
		// appear for awaiting_payment vs paid vs completed vs error. We require
		// only `status`; the rest are optional but flagged if missing for a
		// known completed state.
		$ok = Parcselect_Label_Debug::validate_format(
			$order_id ?: null,
			'session.status',
			$body,
			array( 'status' ),
			array( 'tracking_number', 'label_url', 'txid', 'carrier', 'error', 'qr_uri', 'price_sol', 'price_pln', 'sol_wallet' )
		);
		if ( $ok && is_array( $body ) && 'completed' === ( $body['status'] ?? '' ) ) {
			// Completed sessions MUST ship tracking_number + label_url.
			Parcselect_Label_Debug::validate_format(
				$order_id ?: null,
				'session.status.completed',
				$body,
				array( 'status', 'tracking_number', 'label_url' ),
				array( 'txid', 'carrier' )
			);
		}
		return is_array( $body ) ? $body : new WP_Error( 'bad_response', 'non-json' );
	}

	/**
	 * GET /embed/label/download/:id — fetch completed label PDF.
	 *
	 * @param string $session_id Session ID.
	 * @return array|WP_Error {pdf_binary, filename} or error.
	 */
	public static function download_pdf( $session_id ) {
		$session_id = sanitize_text_field( $session_id );
		$response   = wp_remote_get(
			self::base() . '/embed/label/download/' . rawurlencode( $session_id ),
			array(
				'timeout'   => 30,
				'headers'   => self::auth_headers(),
				'sslverify' => apply_filters( 'parcselect_label_sslverify', true ),
			)
		);
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			return new WP_Error( 'download_failed', sprintf( 'HTTP %d', $code ) );
		}
		$ct = wp_remote_retrieve_header( $response, 'content-type' );
		if ( strpos( (string) $ct, 'application/pdf' ) === false ) {
			return new WP_Error( 'not_pdf', 'Response is not PDF: ' . $ct );
		}
		return array(
			'pdf'      => wp_remote_retrieve_body( $response ),
			'tracking' => (string) wp_remote_retrieve_header( $response, 'x-ps-tracking' ),
		);
	}

	/**
	 * POST /embed/label/confirm — mark session paid with a known TXID.
	 * Triggers map-server's async shipping provider flow server-side.
	 *
	 * @param string $session_id Session ID on map-server.
	 * @param string $txid       On-chain TXID (not validated by confirm endpoint).
	 * @return array|WP_Error
	 */
	public static function confirm( $session_id, $txid, $order_id = 0 ) {
		$secret = trim( (string) get_option( 'parcselect_label_webhook_secret', '' ) );
		if ( empty( $secret ) || strlen( $secret ) < 32 ) {
			Parcselect_Label_Debug::error( $order_id ?: null, 'session.confirm.not_enrolled', array(
				'secret_len' => strlen( $secret ),
			) );
			return new WP_Error( 'not_enrolled', 'ps_label_webhook_secret not set; run enrollment first.' );
		}
		Parcselect_Label_Debug::info( $order_id ?: null, 'session.confirm.request', array(
			'session_id' => substr( $session_id, 0, 12 ) . '…',
			'txid_head'  => substr( (string) $txid, 0, 12 ) . '…',
			'secret_len' => strlen( $secret ),
		) );
		$t0       = microtime( true );
		$response = wp_remote_post(
			self::base() . '/embed/label/confirm',
			array(
				'timeout'   => 15,
				'sslverify' => apply_filters( 'parcselect_label_sslverify', true ),
				'headers'   => array(
					'X-Payout-Secret' => $secret,
					'Content-Type'    => 'application/json',
					'Accept'          => 'application/json',
				),
				'body'    => wp_json_encode(
					array(
						'session_id' => sanitize_text_field( $session_id ),
						'txid'       => preg_replace( '/\s+/', '', sanitize_text_field( $txid ) ),
					)
				),
			)
		);
		$elapsed_ms = (int) ( ( microtime( true ) - $t0 ) * 1000 );
		Parcselect_Label_Debug::log_http( $order_id ?: null, 'session.confirm.http', $response, array( 'elapsed_ms' => $elapsed_ms ) );
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		Parcselect_Label_Debug::validate_format(
			$order_id ?: null,
			'session.confirm',
			$body,
			array( 'ok' ),
			array( 'status', 'tracking_number', 'label_url', 'error' )
		);
		return is_array( $body ) ? $body : new WP_Error( 'bad_response', wp_remote_retrieve_body( $response ) );
	}

	/**
	 * POST /embed/label/quote — get PLN + SOL quote.
	 *
	 * @param array $payload {carrier, size, weight}
	 * @return array|WP_Error
	 */
	public static function quote( array $payload ) {
		$response = wp_remote_post(
			self::base() . '/embed/label/quote',
			array(
				'timeout'   => 10,
				'sslverify' => apply_filters( 'parcselect_label_sslverify', true ),
				'headers'   => self::auth_headers(),
				'body'      => wp_json_encode( $payload ),
			)
		);
		if ( is_wp_error( $response ) ) {
			return $response;
		}
		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		return is_array( $body ) ? $body : new WP_Error( 'bad_response', 'non-json' );
	}
}
