<?php
/**
 * Parcselect_Label_Pdf_Store — pobiera PDF z naszego VPS i zapisuje w DEDYKOWANEJ
 * TABELI BAZY DANYCH klienta (NIE w publicznie serwowanym katalogu uploads/).
 *
 * v4.4.5 M3 — refactor składowania PDF etykiet (WP.org security review).
 *
 * PROBLEM (recenzent WP.org):
 *   Stary model zapisywał PDF w `wp-content/uploads/etykietownik/` — katalog
 *   serwowany publicznie przez web server. nginx ignoruje .htaccess, więc
 *   ktokolwiek znający nazwę pliku ({ORDER_ID}_{TRACKING}.pdf — łatwa do
 *   zgadnięcia) mógł pobrać etykietę z wrażliwymi danymi adresowymi.
 *
 * NOWY MODEL:
 *   - Bajty PDF trafiają do tabeli `{prefix}parcselect_label_pdf` (LONGBLOB).
 *   - Plik NIE jest zapisywany w uploads/ — żaden publiczny URL nie istnieje.
 *   - Pobranie wyłącznie przez uwierzytelniony endpoint admin-ajax
 *     (Parcselect_Label_Pdf_Endpoint) — capability + nonce + streaming.
 *   - Meta `_ps_label_url` / `_ps_label_local_pdf_url` wskazują na ten endpoint.
 *
 * KOMPATYBILNOŚĆ WSTECZNA:
 *   - Zamówienia z istniejącym plikiem (`_ps_label_local_pdf_path` wskazuje
 *     czytelny plik) DALEJ działają — endpoint pobierania fallbackuje do
 *     odczytu starego pliku gdy w tabeli DB brak rekordu.
 *   - Stare pliki są leniwo migrowane do DB przy pierwszym pobraniu
 *     (Parcselect_Label_Pdf_Endpoint::lazy_migrate_file) oraz wsadowo przez
 *     cron (cron_migrate_batch).
 *
 * Single Responsibility: fetch + store-do-DB + meta update + tworzenie tabeli.
 * NIE renderuje, NIE streamuje (to robi Parcselect_Label_Pdf_Endpoint).
 *
 * @package ParcelSelectorFree
 */

defined( 'ABSPATH' ) || exit;

final class Parcselect_Label_Pdf_Store {

	/** Allowed remote hosts (SSRF guard). */
	const ALLOWED_HOSTS = array(
		'parcelselector.com',
		'map.parcelselector.com',
		'etykietownik.parcelselector.com',
	);

	/** Wersja schematu tabeli — bump przy każdej zmianie struktury kolumn. */
	const DB_SCHEMA_VERSION = '1';

	/** option_name w której trzymamy zainstalowaną wersję schematu. */
	const DB_VERSION_OPTION = 'parcselect_label_pdf_db_version';

	/**
	 * @return string Pełna nazwa tabeli PDF (z prefixem WP).
	 */
	public static function table_name() {
		global $wpdb;
		return $wpdb->prefix . 'parcselect_label_pdf';
	}

	public static function init() {
		// Bezpiecznik: jeśli schemat tabeli nieaktualny przy load (np. user
		// skopiował pliki bez uruchomienia activation hook), dociągnij tabelę.
		// Tani short-circuit gdy wersja się zgadza.
		if ( is_admin() && get_option( self::DB_VERSION_OPTION ) !== self::DB_SCHEMA_VERSION ) {
			self::install_table();
		}

		// v4.2 B.7.3 — daily WP cron migration dla orderow z REMOTE _ps_label_url (legacy)
		// + migracja starych plikow z uploads/ do DB.
		if ( ! wp_next_scheduled( 'parcselect_label_pdf_migrate_cron' ) ) {
			wp_schedule_event( time() + 3600, 'daily', 'parcselect_label_pdf_migrate_cron' );
		}
		add_action( 'parcselect_label_pdf_migrate_cron', array( __CLASS__, 'cron_migrate_batch' ) );
	}

	/**
	 * Tworzy / aktualizuje tabelę PDF przez dbDelta(). Idempotentne —
	 * dbDelta() dodaje brakujące kolumny i nie rusza istniejących danych.
	 *
	 * Wywoływane z: register_activation_hook (parcselect.php) oraz lazy z init().
	 */
	public static function install_table() {
		global $wpdb;
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$table           = self::table_name();
		$charset_collate = $wpdb->get_charset_collate();

		// UWAGA dbDelta: dwie spacje po PRIMARY KEY, każda kolumna w osobnej
		// linii, typy małymi literami — wymogi parsera dbDelta.
		$sql = "CREATE TABLE {$table} (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			order_id bigint(20) unsigned NOT NULL DEFAULT 0,
			filename varchar(255) NOT NULL DEFAULT '',
			pdf_blob longblob NOT NULL,
			bytes bigint(20) unsigned NOT NULL DEFAULT 0,
			created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
			PRIMARY KEY  (id),
			KEY order_id (order_id)
		) {$charset_collate};";

		dbDelta( $sql );

		update_option( self::DB_VERSION_OPTION, self::DB_SCHEMA_VERSION, false );

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( 0, 'pdf_store.install_table', array(
				'table'   => $table,
				'version' => self::DB_SCHEMA_VERSION,
			) );
		}
	}

	/**
	 * WP cron: migruje orders. Dwa zadania:
	 *  1. Orders z REMOTE _ps_label_url (legacy) — pobierz z VPS do DB.
	 *  2. Orders z plikiem w uploads/etykietownik (legacy) — przenieś bajty do DB.
	 *
	 * Limit 20 orders/dzien (administracyjne, nie hot-path).
	 */
	public static function cron_migrate_batch() {
		$site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
		$args = array(
			'limit'      => 20,
			'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- zapytanie administracyjne, nie hot-path
				array( 'key' => '_ps_label_url', 'value' => '', 'compare' => '!=' ),
			),
			'return'     => 'ids',
		);
		$ids      = wc_get_orders( $args );
		$migrated = 0;
		foreach ( $ids as $oid ) {
			$o = wc_get_order( $oid );
			if ( ! ( $o instanceof WC_Order ) ) {
				continue;
			}

			// Już jest rekord w DB — nic do roboty.
			if ( self::has_db_pdf( $oid ) ) {
				continue;
			}

			// 1. Stary plik lokalny — przenieś bajty do DB (nie wymaga VPS).
			$file_path = (string) $o->get_meta( '_ps_label_local_pdf_path', true );
			if ( '' !== $file_path && is_readable( $file_path ) ) {
				$r = self::migrate_file_to_db( $o );
				if ( ! is_wp_error( $r ) ) {
					$migrated++;
				}
				continue;
			}

			// 2. REMOTE _ps_label_url — pobierz z VPS do DB.
			$u = (string) $o->get_meta( '_ps_label_url', true );
			if ( '' === $u ) {
				continue;
			}
			$h = strtolower( (string) wp_parse_url( $u, PHP_URL_HOST ) );
			if ( $h === $site_host || '' === $h ) {
				// Lokalny URL (już nasz endpoint) — nic do migracji.
				continue;
			}
			$r = self::migrate_to_local( $o );
			if ( ! is_wp_error( $r ) ) {
				$migrated++;
			}
		}
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( 0, 'pdf_store.cron.migrate_batch', array(
				'migrated' => $migrated,
				'scanned'  => count( $ids ),
			) );
		}
	}

	/**
	 * Pobiera PDF z remote URL i zapisuje w tabeli DB. Zwraca URL
	 * uwierzytelnionego endpointu pobierania (NIGDY publiczny plik).
	 *
	 * @param WC_Order $order
	 * @param string   $remote_url Remote URL (CEB tokenized lub VPS path)
	 * @param string   $tracking   Tracking number (do nazwy "pliku" w nagłówku)
	 * @return string|WP_Error URL endpointu pobierania lub WP_Error.
	 */
	public static function fetch_and_store( $order, $remote_url, $tracking = '' ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return new WP_Error( 'bad_order', 'Invalid order object' );
		}
		$order_id = $order->get_id();
		if ( '' === (string) $remote_url ) {
			return new WP_Error( 'no_remote_url', 'remote_url is empty' );
		}

		// R4 (Konstytucja flow platnosc->etykieta) — IDEMPOTENCJA.
		// Webhook, cron i klik admina moga wywolac fetch dla tego samego
		// zamowienia ROWNOCZESNIE. Bez blokady rownolegly delete+insert w
		// store_blob() moze zostawic tabele PDF w niespojnym stanie
		// (status "completed" + 0 wierszy => endpoint 404 => "zaplacil,
		// nie ma etykiety"). Blokada na 90s — z zapasem na retry+backoff (R5).
		$lock_key = 'parcselect_label_fetch_lock_' . (int) $order_id;
		if ( get_transient( $lock_key ) ) {
			// Inny proces juz pobiera te etykiete. Jesli PDF zdazyl trafic
			// do DB — zwracamy gotowy URL endpointu. Inaczej sygnalizujemy
			// "w toku": callery maja to traktowac jako stan PRZEJSCIOWY,
			// nie jako blad ustawiajacy status zamowienia na error.
			if ( self::has_db_pdf( $order_id ) ) {
				return self::download_url( $order_id );
			}
			return new WP_Error( 'fetch_in_progress', 'Label fetch already in progress for this order' );
		}
		set_transient( $lock_key, 1, 90 );

		try {
			return self::do_fetch_and_store( $order, (int) $order_id, (string) $remote_url, (string) $tracking );
		} finally {
			// Blokada zwalniana ZAWSZE — takze przy return / wyjatku.
			// Fatal PHP pomija finally, ale TTL 90s i tak zwolni blokade.
			delete_transient( $lock_key );
		}
	}

	/**
	 * Wlasciwa logika pobierz-z-VPS + zapisz-do-DB. Owinieta przez
	 * fetch_and_store() w blokade idempotencji (R4). Nie wywolywac wprost.
	 *
	 * @param WC_Order $order
	 * @param int      $order_id
	 * @param string   $remote_url
	 * @param string   $tracking
	 * @return string|WP_Error URL endpointu pobierania lub WP_Error.
	 */
	private static function do_fetch_and_store( $order, $order_id, $remote_url, $tracking ) {
		// SSRF guard.
		$allowed_hosts = apply_filters( 'parcselect_label_pdf_allowed_hosts', self::ALLOWED_HOSTS );
		$parsed        = wp_parse_url( $remote_url );
		$host          = isset( $parsed['host'] ) ? strtolower( $parsed['host'] ) : '';
		if ( '' === $host || ! in_array( $host, $allowed_hosts, true ) ) {
			return new WP_Error( 'ssrf_blocked', sprintf( 'host not in allowed list: %s', $host ) );
		}

		// Normalize URL (jeśli /embed/label/... → absolute).
		$absolute = $remote_url;
		if ( class_exists( 'Parcselect_Label_Ajax' ) && method_exists( 'Parcselect_Label_Ajax', 'normalize_vps_url' ) ) {
			$absolute = Parcselect_Label_Ajax::normalize_vps_url( $remote_url );
		}
		// BUG-T 2026-05-15: NIE dopisuj order_id/cb do URL z tokenem. CEB label-pdf
		// endpoint jest self-contained (token = auth + unikalnosc per order) i
		// zwraca 404 gdy dostanie obce &order_id (sprawdza wlasny CEB order id).
		if ( false === strpos( $absolute, 'token=' ) ) {
			$glue      = ( false === strpos( $absolute, '?' ) ) ? '?' : '&';
			$absolute .= $glue . 'order_id=' . absint( $order_id );
			// BUG-O4: cache-bust dla nginx fastcgi_cache (trzyma 404 przez 5 min).
			$absolute .= '&_cb=' . time();
		}

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'pdf_store.fetch.start', array( 'url' => $absolute ) );
		}

		// R5 (Konstytucja) — pobranie z VPS z RETRY + exp backoff.
		// 3 proby; ponawiamy na bledzie sieci i 5xx, NIE na 4xx ani na 200-nie-PDF.
		// Walidacja magic bytes %PDF. Telemetria per proba do Debug logu.
		$pdf        = '';
		$elapsed_ms = 0;
		$last_err   = null;
		$max_tries  = 3;
		for ( $attempt = 1; $attempt <= $max_tries; $attempt++ ) {
			$t0   = microtime( true );
			// Timeout 15s/proba: pobranie gotowego PDF (~50-150 KB) jest szybkie;
			// 15s to zapas na wolne lacze. Worst-case 3×15 + 3s backoff = ~48s —
			// miesci sie ponizej typowego max_execution_time (60s) na hostingach
			// wspoldzielonych. Filtrowalne, gdyby ktorys VPS byl wolniejszy.
			$_attempt_timeout = (int) apply_filters( 'parcselect_label_fetch_timeout', 15 );
			$resp = wp_remote_get( $absolute, array(
				'timeout'    => $_attempt_timeout,
				'sslverify'  => apply_filters( 'parcselect_label_sslverify', true ),
				'user-agent' => 'ParcelSelector/' . ( defined( 'PARCSELECT_VERSION' ) ? PARCSELECT_VERSION : '0' ),
			) );
			$elapsed_ms = (int) ( ( microtime( true ) - $t0 ) * 1000 );

			$retryable = false;
			if ( is_wp_error( $resp ) ) {
				$last_err  = new WP_Error( 'fetch_failed', $resp->get_error_message(), array( 'elapsed_ms' => $elapsed_ms, 'attempt' => $attempt ) );
				$retryable = true;
			} else {
				$code = (int) wp_remote_retrieve_response_code( $resp );
				if ( 200 === $code ) {
					$body = (string) wp_remote_retrieve_body( $resp );
					if ( strlen( $body ) >= 1000 && 0 === strpos( $body, '%PDF' ) ) {
						$pdf = $body;
						break; // sukces
					}
					// 200 ale nie-PDF — ponowienie nie pomoze.
					$last_err = new WP_Error( 'not_pdf', 'response not a PDF (magic bytes mismatch)', array(
						'bytes'   => strlen( $body ),
						'first10' => substr( $body, 0, 10 ),
						'attempt' => $attempt,
					) );
				} elseif ( $code >= 500 ) {
					$last_err  = new WP_Error( 'http_' . $code, 'VPS server error', array( 'elapsed_ms' => $elapsed_ms, 'attempt' => $attempt ) );
					$retryable = true;
				} else {
					// 4xx — ponowienie nie pomoze.
					$last_err = new WP_Error( 'http_' . $code, 'unexpected response code', array( 'elapsed_ms' => $elapsed_ms, 'attempt' => $attempt ) );
				}
			}

			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::warn( $order_id, 'pdf_store.fetch.attempt', array(
					'attempt'    => $attempt,
					'error'      => is_wp_error( $last_err ) ? $last_err->get_error_code() : 'none',
					'retryable'  => $retryable,
					'elapsed_ms' => $elapsed_ms,
				) );
			}

			if ( ! $retryable || $attempt >= $max_tries ) {
				break;
			}
			sleep( $attempt ); // exp backoff: 1s, 2s.
		}

		if ( '' === $pdf ) {
			return ( $last_err instanceof WP_Error )
				? $last_err
				: new WP_Error( 'fetch_failed', 'Label PDF could not be fetched from VPS' );
		}

		// v4.2.3 — Strip PDF metadata przed zapis (Producer/Creator/Author)
		// żeby klient nie widział 'Furgonetka'/'InPost' w pdfinfo.
		$pdf = self::strip_pdf_metadata( $pdf );

		// Logiczna nazwa "pliku" (do Content-Disposition w endpoincie).
		$tracking_safe = sanitize_file_name( $tracking ? $tracking : 'no-tracking' );
		$filename      = (int) $order_id . '_' . $tracking_safe . '.pdf';

		// Zapis do tabeli DB.
		$row_id = self::store_blob( $order_id, $filename, $pdf );
		if ( is_wp_error( $row_id ) ) {
			return $row_id;
		}

		$bytes = strlen( $pdf );

		// URL uwierzytelnionego endpointu pobierania. NIGDY bezpośredni plik.
		$endpoint_url = self::download_url( $order_id );

		// Update meta.
		// _ps_label_local_pdf_path CELOWO czyszczone — w nowym modelu nie ma
		// pliku na dysku. Stara wartość mogłaby przekierować konsumentów do
		// odczytu nieistniejącego/przestarzałego pliku.
		$order->update_meta_data( '_ps_label_url',           $endpoint_url );
		$order->update_meta_data( '_ps_label_local_pdf_url', $endpoint_url );
		$order->update_meta_data( '_ps_label_local_pdf_path', '' );
		$order->update_meta_data( '_ps_label_pdf_db_id',     (int) $row_id );
		$order->update_meta_data( '_ps_label_pdf_bytes',     $bytes );
		$order->update_meta_data( '_ps_label_pdf_stored_at', current_time( 'mysql' ) );
		$order->save();

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'pdf_store.fetch.ok', array(
				'bytes'        => $bytes,
				'db_id'        => (int) $row_id,
				'endpoint_url' => $endpoint_url,
				'elapsed_ms'   => $elapsed_ms,
			) );
		}

		return $endpoint_url;
	}

	/**
	 * Zapisuje bajty PDF do tabeli DB. Jeden rekord per order — istniejący
	 * rekord dla tego order_id jest nadpisywany (re-fetch / regeneracja).
	 *
	 * @param int    $order_id
	 * @param string $filename Logiczna nazwa pliku.
	 * @param string $pdf      Surowe bajty PDF.
	 * @return int|WP_Error ID rekordu lub WP_Error.
	 */
	public static function store_blob( $order_id, $filename, $pdf ) {
		global $wpdb;
		$table = self::table_name();

		// Usuń poprzedni rekord tego order (regeneracja etykiety).
		$wpdb->delete( $table, array( 'order_id' => (int) $order_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, brak WP API.

		$ok = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, brak WP API.
			$table,
			array(
				'order_id'   => (int) $order_id,
				'filename'   => (string) $filename,
				'pdf_blob'   => $pdf,
				'bytes'      => strlen( $pdf ),
				'created_at' => current_time( 'mysql' ),
			),
			array( '%d', '%s', '%s', '%d', '%s' )
		);

		if ( false === $ok ) {
			return new WP_Error( 'db_insert_fail', 'Cannot write PDF blob to DB table: ' . $wpdb->last_error );
		}
		return (int) $wpdb->insert_id;
	}

	/**
	 * Czy w tabeli DB istnieje rekord PDF dla danego order_id.
	 *
	 * @param int $order_id
	 * @return bool
	 */
	public static function has_db_pdf( $order_id ) {
		global $wpdb;
		$table = self::table_name();
		$found = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE order_id = %d LIMIT 1", (int) $order_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- nazwa tabeli z $wpdb->prefix (zaufana); wartosc przez prepare()
		return null !== $found;
	}

	/**
	 * Pobiera rekord PDF z tabeli DB dla danego order_id.
	 *
	 * @param int $order_id
	 * @return array|null Tablica ['id','filename','pdf_blob','bytes','created_at'] lub null.
	 */
	public static function get_db_pdf( $order_id ) {
		global $wpdb;
		$table = self::table_name();
		$row   = $wpdb->get_row( $wpdb->prepare( "SELECT id, filename, pdf_blob, bytes, created_at FROM {$table} WHERE order_id = %d ORDER BY id DESC LIMIT 1", (int) $order_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- nazwa tabeli z $wpdb->prefix (zaufana); wartosc przez prepare()
		return is_array( $row ) ? $row : null;
	}

	/**
	 * Usuwa rekord(y) PDF z tabeli DB dla danego order_id.
	 * Używane przez data eraser (GDPR) i installation purge.
	 *
	 * @param int $order_id
	 * @return int Liczba usuniętych wierszy.
	 */
	public static function delete_db_pdf( $order_id ) {
		global $wpdb;
		$table   = self::table_name();
		$deleted = $wpdb->delete( $table, array( 'order_id' => (int) $order_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, brak WP API.
		return (int) $deleted;
	}

	/**
	 * BAZOWY URL endpointu pobierania PDF — BEZ nonce. To jest wartość
	 * zapisywana w meta `_ps_label_url` (stała, nigdy nie wygasa).
	 *
	 * Nonce CELOWO nie jest tu dodawany: nonce WordPressa wygasa po 12-24h,
	 * a zapisany w meta uczyniłby etykietę "martwą" po dobie. Nonce dodaje się
	 * świeżo w momencie renderowania strony — patrz nonced_url() / ensure_fresh_nonce().
	 * Bajty PDF żyją bezterminowo w tabeli DB → etykieta dostępna ZAWSZE.
	 *
	 * @param int $order_id
	 * @return string
	 */
	public static function download_url( $order_id ) {
		return add_query_arg(
			array(
				'action'   => 'parcselect_label_pdf',
				'order_id' => absint( $order_id ),
			),
			admin_url( 'admin-ajax.php' )
		);
	}

	/**
	 * Pełny URL pobrania ze ŚWIEŻYM nonce — do użycia W MOMENCIE renderowania
	 * (iframe src, link pobierania). Nonce ważny ~12-24h od wygenerowania strony,
	 * co w zupełności wystarcza na natychmiastowe załadowanie iframe / kliknięcie.
	 *
	 * @param int  $order_id
	 * @param bool $inline   true => podgląd inline w iframe; false => pobranie (attachment).
	 * @return string
	 */
	public static function nonced_url( $order_id, $inline = false ) {
		$args = array( 'nonce' => wp_create_nonce( 'parcselect_label_pdf_' . absint( $order_id ) ) );
		if ( $inline ) {
			$args['inline'] = '1';
		}
		return add_query_arg( $args, self::download_url( $order_id ) );
	}

	/**
	 * Wstrzykuje ŚWIEŻY nonce do URL-a endpointu PDF. Leczy też stare meta
	 * `_ps_label_url` zapisane z wygasłym nonce (przed tą poprawką). Dla URL-a
	 * który NIE jest endpointem parcselect_label_pdf zwraca go bez zmian.
	 *
	 * @param string $url
	 * @return string
	 */
	public static function ensure_fresh_nonce( $url ) {
		$url = (string) $url;
		if ( false === strpos( $url, 'action=parcselect_label_pdf' ) ) {
			return $url;
		}
		$parts = wp_parse_url( $url );
		$oid   = 0;
		if ( ! empty( $parts['query'] ) ) {
			parse_str( $parts['query'], $q );
			$oid = isset( $q['order_id'] ) ? absint( $q['order_id'] ) : 0;
		}
		if ( ! $oid ) {
			return $url;
		}
		$url = remove_query_arg( 'nonce', $url );
		return add_query_arg( 'nonce', wp_create_nonce( 'parcselect_label_pdf_' . $oid ), $url );
	}

	/**
	 * Sprawdza czy dla orderu istnieje PDF (w DB lub w starym pliku).
	 * Zwraca ścieżkę STAREGO pliku jeśli istnieje (kompatybilność wsteczna),
	 * albo null. Nowy model nie ma pliku — DB sprawdzaj przez has_db_pdf().
	 *
	 * @param WC_Order $order
	 * @return string|null Ścieżka starego pliku jeśli czytelna, inaczej null.
	 */
	public static function local_path_for( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return null;
		}
		$p = (string) $order->get_meta( '_ps_label_local_pdf_path', true );
		return ( '' !== $p && is_readable( $p ) ) ? $p : null;
	}

	/**
	 * Zwraca URL uwierzytelnionego endpointu pobierania dla orderu,
	 * jeśli PDF jest dostępny (w DB albo w starym pliku).
	 *
	 * @param WC_Order $order
	 * @return string|null
	 */
	public static function local_url_for( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return null;
		}
		$order_id = $order->get_id();
		if ( self::has_db_pdf( $order_id ) || null !== self::local_path_for( $order ) ) {
			return self::download_url( $order_id );
		}
		return null;
	}

	/**
	 * Migration helper — przenosi PDF do tabeli DB.
	 *  - Order ze STARYM plikiem lokalnym → migrate_file_to_db().
	 *  - Order z REMOTE _ps_label_url → re-fetch z VPS do DB.
	 *
	 * @param WC_Order $order
	 * @return string|WP_Error URL endpointu lub WP_Error.
	 */
	public static function migrate_to_local( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return new WP_Error( 'bad_order', 'Invalid order' );
		}
		$order_id = $order->get_id();

		// Już w DB — zwróć endpoint URL.
		if ( self::has_db_pdf( $order_id ) ) {
			return self::download_url( $order_id );
		}

		// Stary plik lokalny — przenieś bajty do DB bez round-tripu do VPS.
		if ( null !== self::local_path_for( $order ) ) {
			return self::migrate_file_to_db( $order );
		}

		// Inaczej — re-fetch z REMOTE _ps_label_url.
		$remote = (string) $order->get_meta( '_ps_label_url', true );
		if ( '' === $remote ) {
			return new WP_Error( 'no_remote', 'No _ps_label_url to migrate from' );
		}
		$parsed    = wp_parse_url( $remote );
		$host      = isset( $parsed['host'] ) ? strtolower( $parsed['host'] ) : '';
		$site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
		if ( $host === $site_host ) {
			// URL już wskazuje nasz host (endpoint) — nic do migracji.
			return $remote;
		}
		$tracking = (string) $order->get_meta( '_ps_label_tracking_number', true );
		if ( '' === $tracking ) {
			$tracking = (string) $order->get_meta( '_ps_tracking_number', true );
		}
		return self::fetch_and_store( $order, $remote, $tracking );
	}

	/**
	 * Przenosi bajty STAREGO pliku PDF (uploads/etykietownik lub uploads/ps-labels)
	 * do tabeli DB. Po sukcesie aktualizuje meta na endpoint URL i czyści
	 * _ps_label_local_pdf_path. STARY plik NIE jest kasowany tutaj — kasuje
	 * go uninstall.php / data eraser; pozostaje jako bezpieczny fallback do
	 * czasu pełnej weryfikacji migracji.
	 *
	 * @param WC_Order $order
	 * @return string|WP_Error URL endpointu lub WP_Error.
	 */
	public static function migrate_file_to_db( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return new WP_Error( 'bad_order', 'Invalid order' );
		}
		$order_id = $order->get_id();
		$path     = (string) $order->get_meta( '_ps_label_local_pdf_path', true );
		if ( '' === $path || ! is_readable( $path ) ) {
			return new WP_Error( 'no_file', 'No readable legacy file to migrate' );
		}

		// PATH TRAVERSAL GUARD — plik musi leżeć w uploads/.
		// v4.4.5 M3 cross-check: porównanie z separatorem na końcu bazy, by
		// katalog-rodzeństwo z prefiksem (`uploads-evil`) nie dał false-positive.
		$real     = realpath( $path );
		$uploads  = wp_upload_dir();
		$base     = realpath( $uploads['basedir'] );
		$base_sep = ( false !== $base ) ? rtrim( $base, '/\\' ) . DIRECTORY_SEPARATOR : false;
		if ( false === $real || false === $base_sep || 0 !== strpos( $real . DIRECTORY_SEPARATOR, $base_sep ) ) {
			return new WP_Error( 'path_unsafe', 'Legacy file outside uploads dir' );
		}

		$pdf = file_get_contents( $real ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- reading legacy PDF binary for migration; WP_Filesystem unreliable for binary blobs.
		if ( false === $pdf || strlen( $pdf ) < 1000 || 0 !== strpos( $pdf, '%PDF' ) ) {
			return new WP_Error( 'bad_file', 'Legacy file is not a valid PDF' );
		}

		$tracking      = (string) $order->get_meta( '_ps_label_tracking_number', true );
		$tracking_safe = sanitize_file_name( $tracking ? $tracking : 'no-tracking' );
		$filename      = (int) $order_id . '_' . $tracking_safe . '.pdf';

		$row_id = self::store_blob( $order_id, $filename, $pdf );
		if ( is_wp_error( $row_id ) ) {
			return $row_id;
		}

		$endpoint_url = self::download_url( $order_id );
		$order->update_meta_data( '_ps_label_url',           $endpoint_url );
		$order->update_meta_data( '_ps_label_local_pdf_url', $endpoint_url );
		$order->update_meta_data( '_ps_label_local_pdf_path', '' );
		$order->update_meta_data( '_ps_label_pdf_db_id',     (int) $row_id );
		$order->update_meta_data( '_ps_label_pdf_bytes',     strlen( $pdf ) );
		$order->update_meta_data( '_ps_label_pdf_stored_at', current_time( 'mysql' ) );
		$order->save();

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'pdf_store.migrate_file_to_db.ok', array(
				'db_id'      => (int) $row_id,
				'bytes'      => strlen( $pdf ),
				'legacy_src' => $real,
			) );
		}

		return $endpoint_url;
	}

	/**
	 * v4.2.3 — Strip PDF metadata. Zamienia Producer/Creator/Author na 'ParcelSelector vX'.
	 * Zapobiega ujawnieniu Furgonetka/InPost w pdfinfo. Byte-stream replace bez external libs.
	 */
	private static function strip_pdf_metadata( $pdf ) {
		$ver     = defined( 'PARCSELECT_VERSION' ) ? PARCSELECT_VERSION : '4.4.5';
		$generic = 'ParcelSelector v' . $ver;
		$replacements = array(
			array( 'pattern' => '/\/Producer\s*\(([^)]*)\)/', 'sub' => '/Producer (' . $generic . ')' ),
			array( 'pattern' => '/\/Creator\s*\(([^)]*)\)/',  'sub' => '/Creator (' . $generic . ')' ),
			array( 'pattern' => '/\/Author\s*\(([^)]*)\)/',   'sub' => '/Author (Customer)' ),
		);
		foreach ( $replacements as $r ) {
			$result = @preg_replace_callback( $r['pattern'], function() use ( $r ) { return $r['sub']; }, $pdf );
			if ( null !== $result ) {
				$pdf = $result;
			}
		}
		return $pdf;
	}
}
