<?php
/**
 * Label API client — PRODUCTION VERIFIED (30.03.2026).
 *
 * Tested against real API with real credentials:
 *   - OAuth2 password grant (token valid 30 days)
 *   - POST /packages with packageParams wrapper — CONFIRMED
 *   - service_id = numeric int (e.g. 45739378) — CONFIRMED
 *   - PUT /order-commands/{uuid} with real UUID v4 — CONFIRMED
 *   - GET /packages/{id}/label returns real PDF — CONFIRMED (17KB, 1 page)
 *   - Price in response: pricing.price_gross — CONFIRMED
 *   - Gauge auto-detected from dimensions — CONFIRMED
 *   - DELETE /packages/{id} removes draft without charge — CONFIRMED
 *
 * @package ParcelSelector
 * @since   3.4.0
 */

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

class Parcselect_Label_API {

	// VPS proxy — all label API requests go through our server.
	// Credentials stay on VPS, client plugin never touches the shipping API directly.
	const API_URL     = 'https://parcelselector.com';
	// Label API requests are proxied through parcelselector.com VPS.
	// No direct third-party API URLs in the client plugin.
	const SANDBOX_URL = '';
	const DIRECT_URL  = '';
	const TOKEN_CACHE = 'parcselect_label_api_token';

	/** InPost parcel locker sizes — dimensions in cm, max weight 25 kg each. */
	const SIZES = array(
		'A' => array( 'label' => '8 x 38 x 64 cm', 'width' => 38, 'height' => 8, 'depth' => 64 ),
		'B' => array( 'label' => '19 x 38 x 64 cm', 'width' => 38, 'height' => 19, 'depth' => 64 ),
		'C' => array( 'label' => '41 x 38 x 64 cm', 'width' => 38, 'height' => 41, 'depth' => 64 ),
	);

	/**
	 * Make an authenticated API request.
	 *
	 * @param string $method   HTTP method.
	 * @param string $endpoint API endpoint.
	 * @param array  $data     Request body.
	 * @return array|false Decoded JSON or false.
	 */
	private static function request( $method, $endpoint, $data = array() ) {
		$use_direct = '1' === get_option( 'parcselect_label_direct_api', '0' );

		if ( $use_direct ) {
			// Direct mode: authenticate with shipping API OAuth token.
			$token = self::get_token();
			if ( ! $token ) {
				return false;
			}
			$auth_headers = array( 'Authorization' => 'Bearer ' . $token );
		} else {
			// VPS proxy mode: authenticate with installation ID + per-install secret.
			// Credentials stay on VPS — plugin never touches upstream directly.
			$installation_id = get_option( 'parcselect_installation_id', '' );
			$shared_secret   = get_option( 'parcselect_label_proxy_secret', '' );

			// SECURITY: Generate per-install secret on first use (not hardcoded).
			if ( empty( $shared_secret ) && ! empty( $installation_id ) ) {
				$shared_secret = wp_generate_password( 48, true, true );
				update_option( 'parcselect_label_proxy_secret', $shared_secret );
			}

			// HMAC over installation_id only (matches VPS server.py:346 scheme).
			// Timestamp header is sent for forward-compatibility but not part of signature.
			$signature = hash_hmac( 'sha256', $installation_id, $shared_secret );

			$auth_headers = array(
				'X-PS-Installation' => $installation_id,
				'X-PS-Secret'       => $signature,
				'X-PS-Timestamp'    => (string) time(),
			);
		}

		$args = array(
			'method'  => $method,
			'timeout' => 30,
			'headers' => array_merge( $auth_headers, array(
				'Content-Type'  => 'application/json',
				'Accept'        => 'application/json',
			) ),
		);

		if ( ! empty( $data ) && in_array( $method, array( 'POST', 'PUT' ), true ) ) {
			$args['body'] = wp_json_encode( $data );
		}

		$url = self::base_url() . $endpoint;

		Parcselect_Label_Hooks::log( 'API ' . $method . ' ' . $endpoint );

		$response = wp_remote_request( $url, $args );

		if ( is_wp_error( $response ) ) {
			Parcselect_Label_Hooks::log( 'API ERROR: ' . $response->get_error_message(), 'error' );
			return false;
		}

		$code = wp_remote_retrieve_response_code( $response );
		$body = wp_remote_retrieve_body( $response );

		// Label downloads return raw PDF binary + tracking from VPS proxy header.
		if ( strpos( $endpoint, '/label' ) !== false && 200 === $code ) {
			$content_type = wp_remote_retrieve_header( $response, 'content-type' );
			if ( strpos( $content_type, 'application/pdf' ) !== false ) {
				$_tracking = wp_remote_retrieve_header( $response, 'x-ps-tracking' );
				return array(
					'pdf'             => $body,
					'tracking_number' => $_tracking ? sanitize_text_field( $_tracking ) : '',
				);
			}
		}

		$decoded = json_decode( $body, true );

		if ( 200 !== $code && 201 !== $code ) {
			$msg = '';
			if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) ) {
				$parts = array();
				foreach ( $decoded['errors'] as $e ) {
					$parts[] = ( isset( $e['path'] ) ? $e['path'] . ': ' : '' ) . ( isset( $e['message'] ) ? $e['message'] : '' );
				}
				$msg = implode( ' | ', $parts );
			} elseif ( isset( $decoded['message'] ) ) {
				$msg = $decoded['message'];
			} else {
				$msg = $body;
			}

			Parcselect_Label_Hooks::log( 'API ' . $code . ': ' . $msg, 'error' );

			return array(
				'error'   => true,
				'code'    => $code,
				'message' => $msg,
				'raw'     => $decoded,
			);
		}

		return $decoded;
	}

	/**
	 * Get the API base URL.
	 *
	 * @return string
	 */
	private static function base_url() {
		$sandbox = get_option( 'parcselect_label_sandbox', '0' );
		if ( '1' === $sandbox ) {
			return self::SANDBOX_URL;
		}
		$use_direct = get_option( 'parcselect_label_direct_api', '0' );
		if ( '1' === $use_direct ) {
			return self::DIRECT_URL;
		}
		$proxy = trim( (string) get_option( 'parcselect_label_proxy_url', '' ) );
		return $proxy ? rtrim( $proxy, '/' ) : self::API_URL;
	}

	/**
	 * Get OAuth2 access token via PASSWORD GRANT.
	 * Token valid 30 days. Cached in transient for 29 days.
	 *
	 * IMPORTANT: client_credentials grant does NOT give access to account endpoints.
	 * Password grant is required for all /packages, /account/services operations.
	 *
	 * @return string|false Access token or false.
	 */
	public static function get_token() {
		$cached = get_transient( self::TOKEN_CACHE );
		if ( false !== $cached ) {
			return $cached;
		}

		$client_id     = get_option( 'parcselect_label_client_id', '' );
		$client_secret = get_option( 'parcselect_label_client_secret', '' );
		$username      = get_option( 'parcselect_label_username', '' );
		$password      = get_option( 'parcselect_label_password', '' );

		if ( empty( $client_id ) || empty( $client_secret ) || empty( $username ) || empty( $password ) ) {
			return false;
		}

		$response = wp_remote_post( self::base_url() . '/oauth/token', array(
			'timeout' => 15,
			'headers' => array(
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic Auth per RFC 7617
			'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ),
				'Content-Type'  => 'application/x-www-form-urlencoded',
			),
			'body'    => array(
				'grant_type' => 'password',
				'scope'      => 'api',
				'username'   => $username,
				'password'   => $password,
			),
		) );

		if ( is_wp_error( $response ) ) {
			return false;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( empty( $body['access_token'] ) ) {
			return false;
		}

		// Token valid 30 days (2592000s). Cache for 29 days.
		set_transient( self::TOKEN_CACHE, $body['access_token'], 29 * DAY_IN_SECONDS );

		// Store refresh token for future use.
		if ( ! empty( $body['refresh_token'] ) ) {
			update_option( 'parcselect_label_refresh_token', $body['refresh_token'] );
		}

		return $body['access_token'];
	}

	/**
	 * Create a package (draft) via the label API.
	 *
	 * REAL API FORMAT (verified 30.03.2026):
	 *   POST /packages with { "packageParams": { ... } } wrapper
	 *   service_id = numeric int (from GET /account/services)
	 *   Response contains: package_id, pricing.price_gross, parcels[0].gauge
	 *
	 * @param int    $order_id   WooCommerce order ID.
	 * @param array  $dimensions Array with width, height, depth keys.
	 * @param float  $weight     Weight in kg.
	 * @param int    $service_id Numeric service ID.
	 * @return array|false Full API response or false.
	 */
	public static function create_package( $order_id, $dimensions, $weight = 1.0, $service_id = 0 ) {
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return false;
		}

		$locker = $order->get_meta( '_ps_locker_name', true );
		if ( empty( $locker ) ) {
			return false;
		}

		// Service ID: local option if set, otherwise 0 — VPS proxy will fill it.
		if ( empty( $service_id ) ) {
			$service_id = (int) get_option( 'parcselect_label_service_id', 0 );
		}

		// Detect carrier for sender pickup logic.
		$carrier = 'inpost';
		if ( class_exists( 'Parcselect_Label_Carrier' ) ) {
			$carrier = Parcselect_Label_Carrier::detect( $order );
		}

		// Receiver name: shipping first, fallback to billing.
		$recv_first = $order->get_shipping_first_name();
		$recv_last  = $order->get_shipping_last_name();
		if ( empty( trim( $recv_first . $recv_last ) ) ) {
			$recv_first = $order->get_billing_first_name();
			$recv_last  = $order->get_billing_last_name();
		}

		// Receiver — ALWAYS use billing city/postcode.
		// Shipping city in WC can be locker description ("Lidl ul. Warszawska 20 A") — NOT a real city.
		$recv_city     = $order->get_billing_city();
		$recv_postcode = $order->get_billing_postcode();

		$receiver = array(
			'name'         => trim( $recv_first . ' ' . $recv_last ),
			'phone'        => $order->get_billing_phone(),
			'email'        => $order->get_billing_email(),
			'point'        => $locker,
			'city'         => $recv_city,
			'postcode'     => $recv_postcode,
			'country_code' => strtoupper( $order->get_meta( '_ps_locker_country', true ) ) ?: 'PL',
		);

		$recv_street = $order->get_shipping_address_1();
		if ( ! empty( $recv_street ) ) {
			$receiver['street'] = $recv_street;
		}

		// Sender / pickup — label API ALWAYS requires: name (imię+nazwisko), street (z numerem), city, postcode, phone.
		// Use etykietownik settings, fallback to WooCommerce store settings.
		$_s_name    = get_option( 'parcselect_label_sender_name', '' );
		$_s_company = get_option( 'parcselect_label_sender_company', '' );
		$_s_street  = get_option( 'parcselect_label_sender_street', '' );
		$_s_city    = get_option( 'parcselect_label_sender_city', '' ) ?: get_option( 'woocommerce_store_city', '' );
		$_s_post    = get_option( 'parcselect_label_sender_postcode', '' ) ?: get_option( 'woocommerce_store_postcode', '' );
		$_s_phone   = get_option( 'parcselect_label_sender_phone', '' );
		$_s_email   = get_option( 'parcselect_label_sender_email', '' ) ?: get_option( 'admin_email' );

		// Street fallback: WC address + address_2 (for building number).
		if ( empty( $_s_street ) ) {
			$_wc_addr1 = get_option( 'woocommerce_store_address', '' );
			$_wc_addr2 = get_option( 'woocommerce_store_address_2', '' );
			$_s_street = trim( $_wc_addr1 . ( $_wc_addr2 ? ' ' . $_wc_addr2 : '' ) );
		}

		// Name fallback: if not set, use admin display name or blogname (label API needs "Imię Nazwisko").
		if ( empty( $_s_name ) ) {
			$_admin_user = get_user_by( 'email', get_option( 'admin_email' ) );
			$_s_name = $_admin_user ? $_admin_user->display_name : get_option( 'blogname', 'Admin' );
		}

		$pickup = array(
			'name'         => $_s_name,
			'company'      => $_s_company,
			'street'       => $_s_street,
			'postcode'     => $_s_post,
			'city'         => $_s_city,
			'country_code' => 'PL',
			'phone'        => $_s_phone ?: $order->get_billing_phone(),
			'email'        => $_s_email,
		);

		// Add InPost sender drop-off locker (when configured).
		$_carrier_pickup = get_option( 'parcselect_label_pickup_' . $carrier, '' );
		if ( ! empty( $_carrier_pickup ) ) {
			$pickup['point'] = $_carrier_pickup;
		}

		// BUG #21 VALIDATE 2026-05-15: walidacja phone/postcode/name/parcels/locker przed wysłaniem.
		// Bug #22 — używamy istniejącego helpera parcselect_phone_strip_pl_prefix() z parcel-selector.php:40-46.
		// Zwracamy WP_Error → caller (Hooks/UI) widzi error truthy ≠ array z 'package_id' i loguje.
		$_phone_pickup_raw  = (string) ( isset( $pickup['phone'] ) ? $pickup['phone'] : '' );
		$_phone_pickup_norm = function_exists( 'parcselect_phone_strip_pl_prefix' )
			? parcselect_phone_strip_pl_prefix( $_phone_pickup_raw )
			: preg_replace( '/\D/', '', $_phone_pickup_raw );
		if ( strlen( $_phone_pickup_norm ) !== 9 ) {
			$_err = new WP_Error( 'invalid_pickup_phone', sprintf( /* translators: 1: actual digit count, 2: raw phone value */ __( 'The sender phone number must have 9 digits (current: %1$d, raw: %2$s)', 'parcel-selector' ), strlen( $_phone_pickup_norm ), $_phone_pickup_raw ) );
			Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
			return $_err;
		}
		$pickup['phone'] = $_phone_pickup_norm;

		$_phone_recv_raw  = (string) ( isset( $receiver['phone'] ) ? $receiver['phone'] : '' );
		$_phone_recv_norm = function_exists( 'parcselect_phone_strip_pl_prefix' )
			? parcselect_phone_strip_pl_prefix( $_phone_recv_raw )
			: preg_replace( '/\D/', '', $_phone_recv_raw );
		if ( strlen( $_phone_recv_norm ) !== 9 ) {
			$_err = new WP_Error( 'invalid_receiver_phone', sprintf( /* translators: 1: actual digit count, 2: raw phone value */ __( 'The recipient phone number must have 9 digits (current: %1$d, raw: %2$s)', 'parcel-selector' ), strlen( $_phone_recv_norm ), $_phone_recv_raw ) );
			Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
			return $_err;
		}
		$receiver['phone'] = $_phone_recv_norm;

		if ( ! preg_match( '/^\d{2}-\d{3}$/', (string) ( isset( $pickup['postcode'] ) ? $pickup['postcode'] : '' ) ) ) {
			$_err = new WP_Error( 'invalid_pickup_postcode', __( 'The sender postal code must be in the format XX-XXX', 'parcel-selector' ) );
			Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
			return $_err;
		}
		if ( ! preg_match( '/^\d{2}-\d{3}$/', (string) ( isset( $receiver['postcode'] ) ? $receiver['postcode'] : '' ) ) ) {
			$_err = new WP_Error( 'invalid_receiver_postcode', __( 'The recipient postal code must be in the format XX-XXX', 'parcel-selector' ) );
			Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
			return $_err;
		}

		if ( ! preg_match( '/^[\p{L}\s\-]+\s+[\p{L}\s\-]+$/u', trim( (string) ( isset( $pickup['name'] ) ? $pickup['name'] : '' ) ) ) ) {
			$_err = new WP_Error( 'invalid_pickup_name', __( 'Sender name: at least a first name + last name', 'parcel-selector' ) );
			Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
			return $_err;
		}
		if ( ! preg_match( '/^[\p{L}\s\-]+\s+[\p{L}\s\-]+$/u', trim( (string) ( isset( $receiver['name'] ) ? $receiver['name'] : '' ) ) ) ) {
			$_err = new WP_Error( 'invalid_receiver_name', __( 'Recipient name: at least a first name + last name', 'parcel-selector' ) );
			Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
			return $_err;
		}

		// Parcels — zbudujemy tymczasową tablicę z payloadu poniżej i zwalidujemy.
		$_parcels_check = array(
			array(
				'width'  => (int) ( isset( $dimensions['width'] ) ? $dimensions['width'] : 0 ),
				'height' => (int) ( isset( $dimensions['height'] ) ? $dimensions['height'] : 0 ),
				'depth'  => (int) ( isset( $dimensions['depth'] ) ? $dimensions['depth'] : 0 ),
				'weight' => (float) $weight,
			),
		);
		if ( ! is_array( $_parcels_check ) || empty( $_parcels_check ) ) {
			$_err = new WP_Error( 'invalid_parcels', __( 'No parcels', 'parcel-selector' ) );
			Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
			return $_err;
		}
		foreach ( $_parcels_check as $_pi => $_p ) {
			foreach ( array( 'width', 'height', 'depth' ) as $_dim ) {
				if ( empty( $_p[ $_dim ] ) || (int) $_p[ $_dim ] <= 0 ) {
					$_err = new WP_Error( 'invalid_parcel_dim', sprintf( /* translators: 1: dimension name, 2: parcel number */ __( 'Dimension %1$s of parcel no. %2$d must be > 0', 'parcel-selector' ), $_dim, $_pi ) );
					Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
					return $_err;
				}
			}
			if ( empty( $_p['weight'] ) || (float) $_p['weight'] <= 0 ) {
				$_err = new WP_Error( 'invalid_parcel_weight', sprintf( /* translators: %d = parcel number */ __( 'Weight of parcel no. %d must be > 0', 'parcel-selector' ), $_pi ) );
				Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
				return $_err;
			}
		}

		// Locker code odbiorcy (point) — regex ABC12 lub ABC12M (np. WAW01, KRA12A).
		$_receiver_point = (string) ( isset( $receiver['point'] ) ? $receiver['point'] : '' );
		if ( '' !== $_receiver_point && ! preg_match( '/^[A-Z]{3}\d{2}[A-Z]?$/', strtoupper( $_receiver_point ) ) ) {
			$_err = new WP_Error( 'invalid_locker_code', sprintf( /* translators: %s = received locker code */ __( 'The recipient parcel locker code must be in the format ABC12 or ABC12M (received: %s)', 'parcel-selector' ), $_receiver_point ) );
			Parcselect_Label_Hooks::log( 'VALIDATE: ' . $_err->get_error_message(), 'error' );
			return $_err;
		}
		// END BUG #21 VALIDATE.

		// CRITICAL: Label API requires "packageParams" wrapper.
		// service_id=0 is OK in proxy mode — VPS integrator will fill the correct value.
		$data = array(
			'packageParams' => array(
				'service_id'            => (int) $service_id,
				'carrier'               => $carrier,
				'pickup'                => $pickup,
				'receiver'              => $receiver,
				'parcels'               => array(
					array(
						'width'  => (int) $dimensions['width'],
						'height' => (int) $dimensions['height'],
						'depth'  => (int) $dimensions['depth'],
						'weight' => max( 0.1, (float) $weight ),
					),
				),
				'type'                  => 'package',
				'user_reference_number' => (string) $order->get_order_number(),
			),
		);

		return self::request( 'POST', '/packages', $data );
	}

	/**
	 * Order (finalize) a package — required before label generation.
	 * CRITICAL: UUID must be valid UUID v4 format.
	 *
	 * @param string $package_id Package ID from create_package response.
	 * @return array|false Response or false.
	 */
	public static function order_package( $package_id ) {
		$uuid = wp_generate_uuid4();

		$data = array(
			'packages' => array(
				array( 'id' => (string) $package_id ),
			),
		);

		$result = self::request( 'PUT', '/order-commands/' . $uuid, $data );

		if ( $result && ! isset( $result['error'] ) ) {
			$result['command_uuid'] = $uuid;
		}

		return $result;
	}

	/**
	 * Download label PDF for an ordered package.
	 * Package MUST be in "ordered" state (after order_package).
	 *
	 * @param string $package_id Package ID.
	 * @return string|false PDF binary content or false.
	 */
	public static function get_label( $package_id ) {
		$result = self::request( 'GET', '/packages/' . $package_id . '/label' );

		if ( $result && isset( $result['pdf'] ) ) {
			return $result['pdf'];
		}

		return false;
	}

	/**
	 * Delete a draft package (no charge if not ordered).
	 *
	 * @param string $package_id Package ID.
	 * @return bool True if deleted.
	 */
	public static function delete_package( $package_id ) {
		$result = self::request( 'DELETE', '/packages/' . $package_id );
		return false !== $result;
	}

	/**
	 * Get package details.
	 *
	 * @param string $package_id Package ID.
	 * @return array|false Package data or false.
	 */
	public static function get_package( $package_id ) {
		return self::request( 'GET', '/packages/' . $package_id );
	}

	/**
	 * Get available services from the shipping API account.
	 * Returns array of services with numeric IDs.
	 *
	 * @return array|false Services list or false.
	 */
	public static function get_services() {
		$result = self::request( 'GET', '/account/services' );
		if ( $result && isset( $result['services'] ) ) {
			return $result['services'];
		}
		return $result;
	}

	/**
	 * Test API connection — get token + services.
	 *
	 * @return array Test results.
	 */
	public static function test_connection() {
		$results = array();

		// Token test.
		delete_transient( self::TOKEN_CACHE );
		$token = self::get_token();
		$results['token'] = ! empty( $token );

		if ( ! $token ) {
			$results['services'] = false;
			return $results;
		}

		// Services test.
		$services = self::get_services();
		$results['services'] = is_array( $services ) && ! isset( $services['error'] );
		$results['services_data'] = $services;

		return $results;
	}
}
