<?php
/**
 * Wysyłka faktury do KSeF v2 — sesja interaktywna online.
 *
 * Pełny flow:
 *   1. Auth → access_token (cached)
 *   2. Wygeneruj klucz symetryczny AES-256 + IV
 *   3. Zaszyfruj klucz symetryczny RSA-OAEP-SHA256 publicznym kluczem MF (usage=SymmetricKeyEncryption)
 *   4. POST /sessions/online → {referenceNumber} (sessionRef)
 *   5. Wygeneruj XML FA(3) (z Fa3_Builder + Order_Mapper)
 *   6. Zaszyfruj XML AES-256-CBC PKCS#7 + oblicz hashe
 *   7. POST /sessions/online/{sessionRef}/invoices → {referenceNumber} (invoiceRef)
 *   8. POST /sessions/online/{sessionRef}/close
 *   9. Zapis do wp_gksef_outgoing_invoices
 *
 * UPO pobiera Status_Poller (osobny cron lub natychmiast po close).
 *
 * @package KSEF\WordPress\V2
 */

declare(strict_types=1);

namespace KSEF\WordPress\V2;

// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.SlowDBQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Wlasne tabele gksef_*: wartosci przez $wpdb->prepare()/esc_like(), nazwy tabel z $wpdb->prefix, paginacja rzutowana (int); nonce weryfikowany w handlerze (check_ajax_referer/check_admin_referer).

use KSEF\WordPress\Crypto;
use KSEF\WordPress\Invoices\Order_Mapper;
use WP_Error;

class Invoice_Sender {

	public function __construct(
		private Ksef_Client $client,
		private Auth $auth,
		private Public_Keys $public_keys,
		private Fa3_Builder $builder,
	) {}

	/**
	 * Wystawienie faktury KSeF dla pojedynczego zamówienia WC.
	 * Wrapper nad send_data — buduje payload z WC_Order.
	 *
	 * @param array<string,mixed>  $credential
	 * @return array{status:string,session_reference:string,invoice_reference:string,invoice_hash:string,xml:string}|WP_Error
	 */
	public function send( \WC_Order $order, array $credential ) {
		$invoice_data = $this->mapper_data( $order, $credential );
		return $this->send_data( $invoice_data, $credential, $order );
	}

	/**
	 * Wystawienie faktury KSeF z gotowego payload (ręczne wystawianie z admin form lub WC).
	 *
	 * @param array<string,mixed>      $invoice_data Struktura zgodna z Fa3_Builder->build()
	 * @param array<string,mixed>      $credential
	 * @param \WC_Order|null          $order        opcjonalne — gdy z WC, do persist + order meta
	 * @return array<string,mixed>|WP_Error
	 */
	public function send_data( array $invoice_data, array $credential, ?\WC_Order $order = null ) {
		global $wpdb;
		$table   = $wpdb->prefix . 'gksef_outgoing_invoices';
		$cred_id = (int) ( $credential['id'] ?? 0 );
		$inv_no  = trim( (string) ( $invoice_data['invoice_number'] ?? '' ) );
		$lock_key = 'ksef_send_lock_' . md5( $cred_id . '|' . $inv_no );

		// ── IDEMPOTENCJA: nie wyślij dwa razy tej samej faktury ──
		if ( '' !== $inv_no ) {
			$dup = $wpdb->get_var( $wpdb->prepare(
				"SELECT id FROM {$table} WHERE credential_id = %d AND invoice_number = %s
				 AND status IN ('sent','pending','confirmed','sent_from_draft') ORDER BY id DESC LIMIT 1",
				$cred_id, $inv_no
			) );
			if ( $dup ) {
				return new WP_Error(
					'ksef_duplicate_invoice',
					sprintf( 'Faktura nr „%s” została już wysłana do KSeF (wpis #%d). Aby wysłać ponownie, użyj innego numeru faktury.', $inv_no, (int) $dup ),
					[ 'existing_id' => (int) $dup, 'remediation' => 'Zmień numer faktury albo otwórz istniejący wpis.' ]
				);
			}
			// Blokada na czas wysyłki (anty-podwójny-klik / równoległe żądania).
			if ( get_transient( $lock_key ) ) {
				return new WP_Error(
					'ksef_send_in_progress',
					'Ta faktura jest właśnie wysyłana. Poczekaj kilka sekund i odśwież listę przed kolejną próbą.',
					[ 'remediation' => 'Poczekaj na zakończenie trwającej wysyłki.' ]
				);
			}
			set_transient( $lock_key, 1, 3 * MINUTE_IN_SECONDS );
		}

		try {
			$result = $this->do_send_data( $invoice_data, $credential, $order );
		} finally {
			if ( '' !== $inv_no ) {
				delete_transient( $lock_key );
			}
		}
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// ── NATYCHMIASTOWE POTWIERDZENIE: dociągnij status+UPO z MF (async) ──
		$new_id = (int) ( $result['invoice_id'] ?? 0 );
		if ( $new_id > 0 ) {
			$final = $this->confirm_now( $new_id, $credential );
			$result['status']        = $final['status'];                 // confirmed|rejected|pending
			$result['final_status']  = $final['status'];
			$result['ksef_number']   = $final['ksef_number'] ?? null;
			$result['reject_reason'] = $final['reason'] ?? null;
		}
		return $result;
	}

	/**
	 * Po wysłaniu — odpytaj MF kilka razy (z narastającym odstępem), aby od razu
	 * pokazać użytkownikowi finalny stan (zaakceptowana/odrzucona) zamiast „wysłana”.
	 * Resztę (gdy MF wolniejszy) dobije cron/poll endpoint.
	 *
	 * @param array<string,mixed> $credential
	 * @return array{status:string,ksef_number:?string,reason:?string}
	 */
	private function confirm_now( int $invoice_id, array $credential ): array {
		global $wpdb;
		$table = $wpdb->prefix . 'gksef_outgoing_invoices';
		require_once __DIR__ . '/class-status-poller.php';
		require_once __DIR__ . '/class-upo-parser.php';
		$poller = new Status_Poller( $this->client, $this->auth, new Upo_Parser() );
		// Synchroniczne potwierdzanie ma być SZYBKIE — krótki timeout i BEZ retry
		// (wolne/niedostępne MF → szybki fallback do „oczekuje”, cron dobije w tle).
		$this->client->with_timeout( 8 )->with_retry( false );

		$state   = 'pending';
		$delays  = [ 0, 2, 3 ];      // do ~5 s odstępów; reszta przez cron
		$budget  = 12.0;             // twardy limit zegarowy (s) na całe potwierdzanie
		$started = microtime( true );
		foreach ( $delays as $d ) {
			if ( $d > 0 ) {
				sleep( $d );
			}
			if ( microtime( true ) - $started > $budget ) {
				break;
			}
			$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $invoice_id ), ARRAY_A );
			if ( ! $row ) {
				break;
			}
			$state = $poller->finalize_invoice( $row, $credential );
			if ( 'confirmed' === $state || 'rejected' === $state ) {
				break;
			}
			if ( microtime( true ) - $started > $budget ) {
				break;
			}
		}
		$final = $wpdb->get_row( $wpdb->prepare( "SELECT status, ksef_reference_number, offline_reason FROM {$table} WHERE id = %d", $invoice_id ), ARRAY_A );
		return [
			'status'      => (string) ( $final['status'] ?? $state ),
			'ksef_number' => ! empty( $final['ksef_reference_number'] ) ? (string) $final['ksef_reference_number'] : null,
			'reason'      => ! empty( $final['offline_reason'] ) ? (string) $final['offline_reason'] : null,
		];
	}

	/**
	 * Właściwa wysyłka (sesja online → wyślij → close → persist). Wrapper send_data
	 * obudowuje to idempotencją i natychmiastowym potwierdzeniem.
	 *
	 * @param array<string,mixed>      $invoice_data
	 * @param array<string,mixed>      $credential
	 * @param \WC_Order|null          $order
	 * @return array<string,mixed>|WP_Error
	 */
	private function do_send_data( array $invoice_data, array $credential, ?\WC_Order $order = null ) {
		// Wersja darmowa: wystawianie bez limitów (brak bramki quota).
		// 1. Token
		$access_token = $this->auth->get_access_token( $credential );
		if ( is_wp_error( $access_token ) ) {
			return $access_token;
		}
		$this->client->with_bearer( $access_token );

		// 1. Klucz symetryczny + IV
		$session_key = Crypto::ksef_generate_session_key();

		// 2. Pobierz aktywny klucz SymmetricKeyEncryption MF
		$sym_pub = $this->public_keys->get_active_key( 'SymmetricKeyEncryption' );
		if ( is_wp_error( $sym_pub ) ) {
			return $sym_pub;
		}

		// 3. Encrypt session key RSA-OAEP
		try {
			$encrypted_sym_key = Crypto::ksef_rsa_oaep_sha256( $session_key['key'], $sym_pub['certificatePem'] );
		} catch ( \Throwable $e ) {
			return new WP_Error( 'ksef_sym_encrypt', 'Nie udało się zaszyfrować klucza symetrycznego: ' . $e->getMessage() );
		}

		// 4. Open online session
		$open = $this->client->post(
			'/sessions/online',
			[
				'formCode'   => [
					'systemCode'    => 'FA (3)',
					'schemaVersion' => '1-0E',
					'value'         => 'FA',
				],
				'encryption' => [
					'encryptedSymmetricKey' => base64_encode( $encrypted_sym_key ),
					'initializationVector'  => base64_encode( $session_key['iv'] ),
					'publicKeyId'           => $sym_pub['publicKeyId'],
				],
			]
		);
		if ( is_wp_error( $open ) ) {
			return $open;
		}
		$session_ref = (string) ( $open['referenceNumber'] ?? '' );
		if ( $session_ref === '' ) {
			return new WP_Error( 'ksef_session_open_empty', 'Brak referenceNumber po POST /sessions/online', $open );
		}

		// 5. Build XML (z payloadu przekazanego do send_data)
		try {
			$xml = $this->builder->build( $invoice_data );
		} catch ( \Throwable $e ) {
			$this->close_session( $session_ref ); // best-effort close
			return new WP_Error( 'ksef_xml_build', 'Błąd budowania XML: ' . $e->getMessage() );
		}

		// 5b. Walidacja XSD — łapiemy błędy ZANIM wyślemy do KSeF (uniknięcie odrzucenia).
		$xsd_errors = $this->builder->validate( $xml );
		if ( ! empty( $xsd_errors ) ) {
			$this->close_session( $session_ref );
			return new WP_Error(
				'ksef_xsd_invalid',
				'Faktura nie przeszła walidacji XSD FA(3): ' . implode( '; ', array_slice( $xsd_errors, 0, 5 ) ),
				[ 'errors' => $xsd_errors ]
			);
		}

		// 6. Hash + encrypt
		$invoice_bytes        = $xml;
		$invoice_hash_b64     = Crypto::ksef_sha256_base64( $invoice_bytes );
		$invoice_size         = strlen( $invoice_bytes );

		try {
			$encrypted_bytes = Crypto::ksef_aes_256_cbc_encrypt( $invoice_bytes, $session_key['key'], $session_key['iv'] );
		} catch ( \Throwable $e ) {
			$this->close_session( $session_ref );
			return new WP_Error( 'ksef_aes_encrypt', 'AES encrypt faktury failed: ' . $e->getMessage() );
		}

		$encrypted_hash_b64 = Crypto::ksef_sha256_base64( $encrypted_bytes );
		$encrypted_size     = strlen( $encrypted_bytes );

		// 7. POST invoice — spec field names: encryptedInvoice* (NIE encryptedDocument*)
		$send = $this->client->post(
			"/sessions/online/{$session_ref}/invoices",
			[
				'invoiceHash'             => $invoice_hash_b64,
				'invoiceSize'             => $invoice_size,
				'encryptedInvoiceHash'    => $encrypted_hash_b64,
				'encryptedInvoiceSize'    => $encrypted_size,
				'encryptedInvoiceContent' => base64_encode( $encrypted_bytes ),
			]
		);
		if ( is_wp_error( $send ) ) {
			$this->close_session( $session_ref );
			return $send;
		}
		$invoice_ref = (string) ( $send['referenceNumber'] ?? $send['elementReferenceNumber'] ?? '' );

		// 8. Close session
		$this->close_session( $session_ref );

		// 9. Persist outgoing invoice (manual: order=null, używamy danych z payloadu)
		$new_id = $this->persist_outgoing( $order, $credential, $session_ref, $invoice_ref, $xml, $invoice_hash_b64, $invoice_data );

		// 10. Auto-wysyłka faktury do nabywcy (jeśli włączona w ustawieniach i znamy email).
		if ( $new_id > 0 ) {
			require_once KSEF_WORDPRESS_PATH . 'includes/class-invoice-pdf.php';
			\KSEF\WordPress\Invoice_PDF::maybe_send_after_issue( $new_id, $invoice_data );
		}

		return [
			'status'            => 'sent',
			'invoice_id'        => $new_id,
			'session_reference' => $session_ref,
			'invoice_reference' => $invoice_ref,
			'invoice_hash'      => $invoice_hash_b64,
			'xml'               => $xml,
		];
	}

	private function close_session( string $session_ref ): void {
		// best-effort, ignorujemy błąd
		$this->client->post( "/sessions/online/{$session_ref}/close" );
	}

	/**
	 * Zapisz fakturę jako draft (status='draft') — bez wysyłki do KSeF.
	 * XML jest pre-generowany żeby walidacja XSD znalazła błędy zanim user kliknie "Wyślij".
	 *
	 * @param array<string,mixed>  $invoice_data
	 * @param array<string,mixed>  $credential
	 * @return array{draft_id:int,invoice_number:string,xml:string}|WP_Error
	 */
	public function save_draft( array $invoice_data, array $credential, int $existing_id = 0 ) {
		try {
			$xml = $this->builder->build( $invoice_data );
		} catch ( \Throwable $e ) {
			return new WP_Error( 'ksef_xml_build', 'Błąd budowania XML: ' . $e->getMessage() );
		}
		// Pre-flight walidacja XSD — draft to dobre miejsce żeby pokazać błędy
		// zanim user kliknie "Wyślij". Nie blokuje zapisu, ale dołącza ostrzeżenia.
		$xsd_errors = $this->builder->validate( $xml );

		global $wpdb;
		$table = $wpdb->prefix . 'gksef_outgoing_invoices';
		$paid_initial = (float) ( $invoice_data['payment']['paid'] ?? 0 );
		$total_gross  = (float) ( $invoice_data['total_gross'] ?? 0 );
		$payment_status = $paid_initial >= $total_gross && $total_gross > 0 ? 'paid' : ( $paid_initial > 0 ? 'partial' : 'unpaid' );
		$fields = [
				'credential_id'        => (int) $credential['id'],
				'wc_order_id'          => 0,
				'status'               => 'draft',
				'invoice_number'       => (string) ( $invoice_data['invoice_number'] ?? '' ),
				'seller_nip'           => (string) ( $invoice_data['seller']['nip'] ?? '' ),
				'buyer_nip'            => (string) ( $invoice_data['buyer']['nip'] ?? '' ),
				'buyer_name'           => (string) ( $invoice_data['buyer']['name'] ?? '' ),
				'gross_amount'         => $total_gross,
				'net_amount'           => (float) ( $invoice_data['total_net'] ?? 0 ),
				'vat_amount'           => (float) ( $invoice_data['total_vat'] ?? 0 ),
				'currency'             => (string) ( $invoice_data['currency'] ?? 'PLN' ),
				'due_date'             => ! empty( $invoice_data['payment']['due_date'] ) ? gmdate( 'Y-m-d', strtotime( (string) $invoice_data['payment']['due_date'] ) ) : null,
				'payment_status'       => $payment_status,
				'paid_amount'          => $paid_initial,
				'notes'                => (string) ( $invoice_data['notes'] ?? '' ),
				'additional_markers'   => ! empty( $invoice_data['markers'] ) ? wp_json_encode( $invoice_data['markers'] ) : null,
				'related_docs'         => ! empty( $invoice_data['related_docs'] ) ? wp_json_encode( $invoice_data['related_docs'] ) : null,
				'extra_data'           => ! empty( $invoice_data['extra_data'] ) ? wp_json_encode( $invoice_data['extra_data'] ) : null,
				'delivery_address'     => ! empty( $invoice_data['delivery_address'] ) ? wp_json_encode( $invoice_data['delivery_address'] ) : null,
				'generated_xml'        => \KSEF\WordPress\Crypto::encrypt_for_storage( $xml ),
				'payload_json'         => \KSEF\WordPress\Crypto::encrypt_for_storage( wp_json_encode( $invoice_data ) ),
		];
		// UPDATE istniejacego draftu zamiast duplikatu (edycja / autozapis).
		if ( $existing_id > 0 ) {
			$is_draft = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d AND status = 'draft'", $existing_id ) );
			if ( $is_draft > 0 ) {
				unset( $fields['wc_order_id'] );
				$wpdb->update( $table, $fields, [ 'id' => $existing_id ] );
				return [
					'draft_id'       => $existing_id,
					'invoice_number' => (string) ( $invoice_data['invoice_number'] ?? '' ),
					'xml'            => $xml,
					'xsd_errors'     => $xsd_errors,
				];
			}
		}
		$wpdb->insert( $table, $fields );
		$draft_id = (int) $wpdb->insert_id;
		return [
			'draft_id'       => $draft_id,
			'invoice_number' => (string) ( $invoice_data['invoice_number'] ?? '' ),
			'xml'            => $xml,
			'xsd_errors'     => $xsd_errors,   // [] gdy zgodny; lista błędów = ostrzeżenie w UI
		];
	}

	/**
	 * Wystawienie faktury LOKALNIE z zamówienia WC — bez wysyłki do KSeF.
	 * Buduje + waliduje XML FA(3) i zapisuje rekord ze statusem 'issued' (faktura istnieje,
	 * ma numer/dane i PDF, ale NIE jest wysłana do KSeF). Wysyłka do KSeF następuje osobno
	 * (send_draft na tym rekordzie). Idempotentne per zamówienie.
	 *
	 * @param array<string,mixed> $credential
	 * @return array<string,mixed>|WP_Error
	 */
	public function issue_local( \WC_Order $order, array $credential ) {
		global $wpdb;
		$table    = $wpdb->prefix . 'gksef_outgoing_invoices';
		$order_id = (int) $order->get_id();

		// Idempotencja — już wystawiona (lokalnie) lub wysłana dla tego zamówienia.
		$exists = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT id FROM {$table} WHERE wc_order_id = %d AND status IN ('issued','sent','pending','confirmed','sent_from_draft') ORDER BY id DESC LIMIT 1",
			$order_id
		) );
		if ( $exists > 0 ) {
			return [ 'status' => 'issued', 'invoice_id' => $exists, 'duplicate' => true ];
		}

		$invoice_data = $this->mapper_data( $order, $credential );

		try {
			$xml = $this->builder->build( $invoice_data );
		} catch ( \Throwable $e ) {
			return new WP_Error( 'ksef_xml_build', 'Błąd budowania XML: ' . $e->getMessage() );
		}
		$xsd_errors = $this->builder->validate( $xml );
		if ( ! empty( $xsd_errors ) ) {
			return new WP_Error(
				'ksef_xsd_invalid',
				'Faktura nie przeszła walidacji XSD FA(3): ' . implode( '; ', array_slice( $xsd_errors, 0, 5 ) ),
				[ 'errors' => $xsd_errors ]
			);
		}

		$paid_initial = (float) ( $invoice_data['payment']['paid'] ?? 0 );
		$total_gross  = (float) ( $invoice_data['total_gross'] ?? 0 );
		$payment_status = 'unpaid';
		if ( $paid_initial >= $total_gross && $total_gross > 0 ) {
			$payment_status = 'paid';
		} elseif ( $paid_initial > 0 ) {
			$payment_status = 'partial';
		}

		$wpdb->insert( $table, [
			'credential_id'        => (int) $credential['id'],
			'wc_order_id'          => $order_id,
			'status'               => 'issued', // wystawiona lokalnie, NIE wysłana do KSeF
			'invoice_number'       => (string) ( $invoice_data['invoice_number'] ?? '' ),
			'seller_nip'           => (string) ( $invoice_data['seller']['nip'] ?? '' ),
			'buyer_nip'            => (string) ( $invoice_data['buyer']['nip'] ?? '' ),
			'buyer_name'           => (string) ( $invoice_data['buyer']['name'] ?? '' ),
			'gross_amount'         => $total_gross,
			'net_amount'           => (float) ( $invoice_data['total_net'] ?? 0 ),
			'vat_amount'           => (float) ( $invoice_data['total_vat'] ?? 0 ),
			'currency'             => (string) ( $invoice_data['currency'] ?? 'PLN' ),
			'due_date'             => ! empty( $invoice_data['payment']['due_date'] ) ? gmdate( 'Y-m-d', strtotime( (string) $invoice_data['payment']['due_date'] ) ) : null,
			'payment_status'       => $payment_status,
			'paid_amount'          => $paid_initial,
			'notes'                => (string) ( $invoice_data['notes'] ?? '' ),
			'additional_markers'   => ! empty( $invoice_data['markers'] ) ? wp_json_encode( $invoice_data['markers'] ) : null,
			'related_docs'         => ! empty( $invoice_data['related_docs'] ) ? wp_json_encode( $invoice_data['related_docs'] ) : null,
			'extra_data'           => ! empty( $invoice_data['extra_data'] ) ? wp_json_encode( $invoice_data['extra_data'] ) : null,
			'delivery_address'     => ! empty( $invoice_data['delivery_address'] ) ? wp_json_encode( $invoice_data['delivery_address'] ) : null,
			'generated_xml'        => \KSEF\WordPress\Crypto::encrypt_for_storage( $xml ),
			'payload_json'         => \KSEF\WordPress\Crypto::encrypt_for_storage( wp_json_encode( $invoice_data ) ),
			'submission_timestamp' => current_time( 'mysql', true ),
			'created_by'           => get_current_user_id(),
		] );
		$new_id = (int) $wpdb->insert_id;

		$order->update_meta_data( '_ksef_invoice_local_issued', '1' );
		$order->update_meta_data( '_ksef_invoice_status', 'issued' );
		$order->save();

		return [ 'status' => 'issued', 'invoice_id' => $new_id, 'invoice_number' => (string) ( $invoice_data['invoice_number'] ?? '' ), 'xml' => $xml ];
	}

	/**
	 * Wysłanie draftu (z bazy) do KSeF — odczytuje payload_json i robi pełen send_data.
	 *
	 * @param array<string,mixed> $credential
	 * @return array<string,mixed>|WP_Error
	 */
	public function send_draft( int $draft_id, array $credential ) {
		global $wpdb;
		$row = $wpdb->get_row(
			$wpdb->prepare(
				// Faktury jeszcze nie w KSeF — draft, wystawione lokalnie ('issued'), nieudane ('failed')
				// oraz odrzucone ('rejected') — doślij/ponów bez duplikatu (wysyłka istniejącego dokumentu).
				"SELECT * FROM {$wpdb->prefix}gksef_outgoing_invoices WHERE id = %d AND status IN ('draft','issued','failed','rejected')",
				$draft_id
			),
			ARRAY_A
		);
		if ( ! $row ) {
			return new WP_Error( 'draft_not_found', 'Faktura (draft / wystawiona lokalnie) nie istnieje albo już wysłana.' );
		}
		$payload = json_decode( (string) \KSEF\WordPress\Crypto::decrypt_from_storage( $row['payload_json'] ?? '' ), true );
		if ( ! is_array( $payload ) ) {
			return new WP_Error( 'draft_no_payload', 'Brak danych payload dla draftu.' );
		}
		// Wywołaj send_data — utworzy nowy wpis z status='sent'. Stary draft oznaczamy 'sent_replaced'.
		$result = $this->send_data( $payload, $credential, null );
		if ( ! is_wp_error( $result ) ) {
			$wpdb->update(
				"{$wpdb->prefix}gksef_outgoing_invoices",
				[ 'status' => 'sent_from_draft' ],
				[ 'id' => $draft_id ]
			);
		}
		return $result;
	}

	/**
	 * Rozdziela „Ulica i numer" na nazwę ulicy + numer (FA(3) Ulica + NrDomu).
	 * Gdy brak rozpoznawalnego numeru — całość traktowana jako nazwa ulicy.
	 *
	 * @return array{0:string,1:string} [ulica, numer]
	 */
	private static function split_street( string $full ): array {
		$full = trim( $full );
		if ( '' === $full ) {
			return [ '', '' ];
		}
		if ( preg_match( '/^(.*?)[\s,]+(\d+[A-Za-z]?(?:\s*\/\s*\d+[A-Za-z]?)?)$/u', $full, $m ) ) {
			return [ trim( $m[1] ), preg_replace( '/\s+/', '', $m[2] ) ];
		}
		return [ $full, '' ];
	}

	/**
	 * Map z WC_Order na strukturę wymaganą przez Fa3_Builder.
	 * Reuse istniejącego Order_Mapper (zwraca seller/buyer/items/...).
	 *
	 * @return array<string,mixed>
	 */
	private function mapper_data( \WC_Order $order, array $credential ): array {
		require_once dirname( __DIR__ ) . '/invoices/class-order-mapper.php';

		$mapper = new Order_Mapper( $order, $credential );
		$legacy = $mapper->map();

		// Czy klient firmowy? Decyduje obecność poprawnego NIP (10 cyfr) — pole „firma" w
		// checkout bywa puste mimo NIP-u (klient wpisuje nazwisko), a NIP = faktura na firmę.
		$nip    = preg_replace( '/\D+/', '', (string) $order->get_meta( '_billing_nip' ) ) ?? '';
		$is_b2c = strlen( (string) $nip ) !== 10;

		// Adapter: items[i] z legacy → format wymagany przez Fa3_Builder
		$items = [];
		foreach ( ( $legacy['items'] ?? [] ) as $li ) {
			$qty        = (float) ( $li['quantity'] ?? $li['qty'] ?? 1 );
			// Order_Mapper zwraca unit_net_price / net_amount / gross_amount — czytamy z pełnym fallbackiem.
			$net_price  = (float) ( $li['unit_price_net'] ?? $li['unit_net_price'] ?? $li['net_price'] ?? 0 );
			$vat_rate   = (float) ( $li['vat_rate'] ?? 23 );
			$vat_amount = (float) ( $li['vat_amount'] ?? round( $net_price * $qty * $vat_rate / 100, 2 ) );
			$line_net   = (float) ( $li['line_net'] ?? $li['net_amount'] ?? round( $net_price * $qty, 2 ) );
			$line_gross = (float) ( $li['line_gross'] ?? $li['gross_amount'] ?? $li['gross_price'] ?? round( $line_net + $vat_amount, 2 ) );

			$items[] = [
				'description'    => (string) ( $li['description'] ?? $li['name'] ?? '' ),
				'unit'           => (string) ( $li['unit'] ?? 'szt.' ),
				'quantity'       => $qty,
				'unit_price_net' => $net_price,
				'vat_rate'       => $vat_rate,
				'line_net'       => $line_net,
				'line_gross'     => $line_gross,
			];
		}

		// VAT summary — grupuj WSZYSTKIE pozycje (produkty + dostawa + opłaty) po stawce, w groszach.
		// Wcześniej brano stawki z totals.vat_by_rate (Order_Mapper::group_vat_by_rate liczy tylko
		// line_items WC) — pomijało dostawę/opłaty, przez co w XML (P_13/P_14) i PDF brakowało np. 23%.
		$totals     = (array) ( $legacy['totals'] ?? [] );
		$vs_buckets = [];
		foreach ( $items as $item ) {
			$rate_raw = $item['vat_rate'] ?? 0;
			$key      = is_numeric( $rate_raw ) ? (string) (int) round( (float) $rate_raw ) : (string) $rate_raw;
			if ( ! isset( $vs_buckets[ $key ] ) ) {
				$vs_buckets[ $key ] = [ 'rate' => is_numeric( $rate_raw ) ? (float) $rate_raw : $rate_raw, 'net_g' => 0, 'vat_g' => 0 ];
			}
			$net_g = (int) round( (float) ( $item['line_net'] ?? 0 ) * 100 );
			$gro_g = (int) round( (float) ( $item['line_gross'] ?? 0 ) * 100 );
			$vs_buckets[ $key ]['net_g'] += $net_g;
			$vs_buckets[ $key ]['vat_g'] += ( $gro_g - $net_g );
		}
		$vat_summary = [];
		foreach ( $vs_buckets as $b ) {
			$vat_summary[] = [
				'rate'       => $b['rate'],
				'base_net'   => $b['net_g'] / 100,
				'vat_amount' => $b['vat_g'] / 100,
			];
		}

		// Dane nabywcy. Priorytet: (1) dane potwierdzone/poprawione ręcznie na checkoucie
		// (meta _ksef_buyer_*), (2) gdy ich brak a jest NIP firmowy — pobranie z GUS (Pro + licencja),
		// (3) fallback: dane z zamówienia.
		$buyer       = (array) ( $legacy['buyer'] ?? [] );
		$edited_name = trim( (string) $order->get_meta( '_ksef_buyer_name' ) );
		// Dane nabywcy wpisane ręcznie (firma z GUS LUB osoba fizyczna „inne dane do faktury").
		// Dla osoby fizycznej (B2C) faktura imienna — bez NIP.
		if ( '' !== $edited_name ) {
			$buyer['name']    = $edited_name;
			$buyer['nip']     = $is_b2c ? '' : (string) $nip;
			$split            = self::split_street( (string) $order->get_meta( '_ksef_buyer_street' ) );
			$buyer['address'] = [
				'street'        => $split[0],
				'street_number' => $split[1],
				'city'          => (string) $order->get_meta( '_ksef_buyer_city' ),
				'postal_code'   => (string) $order->get_meta( '_ksef_buyer_zip' ),
				'country'       => 'PL',
			];
		}

		// Numer faktury — ze skonfigurowanego schematu numeracji (Ustawienia → Numeracja faktur),
		// spójnie z ręcznym wystawianiem (ta sama seria). Przydzielany DOKŁADNIE RAZ per zamówienie
		// (idempotencja przez meta _ksef_invoice_number) — retry/promote nie pali licznika.
		require_once __DIR__ . '/class-invoice-numbering.php';
		$invoice_number = (string) $order->get_meta( '_ksef_invoice_number' );
		if ( '' === trim( $invoice_number ) ) {
			$invoice_number = (string) Invoice_Numbering::next( null, 'sale' );
			$order->update_meta_data( '_ksef_invoice_number', $invoice_number );
			$order->save();
		}

		return [
			'seller'        => (array) ( $legacy['seller'] ?? [] ),
			'buyer'         => $buyer,
			'items'         => $items,
			'vat_summary'   => $vat_summary,
			'total_net'     => (float) ( $totals['net_amount']   ?? 0 ),
			'total_vat'     => (float) ( $totals['vat_total']    ?? 0 ),
			'total_gross'   => (float) ( $totals['gross_amount'] ?? $order->get_total() ),
			'currency'      => (string) ( $order->get_currency() ?: 'PLN' ),
			'invoice_number' => $invoice_number,
			'issue_place'   => (string) ( $legacy['issue_place'] ?? '' ),
			// extra_data trafia do payload_json + kolumny extra_data (display/kopiuj fakturę czyta stąd
			// „Miejsce wystawienia"/„Nr klienta") — spójnie z ręcznym wystawianiem.
			'extra_data'    => [
				'customer_no' => (string) ( $buyer['customer_no'] ?? '' ),
				'issue_place' => (string) ( $legacy['issue_place'] ?? '' ),
			],
			'issue_date'    => (string) ( $legacy['issue_date'] ?? gmdate( 'Y-m-d' ) ),
			'sale_date'     => (string) ( $legacy['issue_date'] ?? gmdate( 'Y-m-d' ) ),
			'rodzaj_faktury' => 'VAT',
			'is_b2c'        => $is_b2c,
			'adnotacje'     => [
				'P_16'  => '0',
				'P_17'  => '0',
				'P_18'  => '0',
				'P_18A' => '0',
				'P_19N' => '1',
				'P_22N' => '1',
				'P_23'  => '0',
			],
			'payment'       => ( function () use ( $order, $legacy ) {
				// Zamówienie opłacone w sklepie WooCommerce → termin płatności = data zapłaty
				// (bez fikcyjnych +14 dni). Nieopłacone → standardowo +14 dni i bez „Zapłacono".
				$is_paid  = $order->is_paid();
				$dp       = $order->get_date_paid();
				$paid_ymd = $dp ? $dp->date( 'Y-m-d' ) : (string) ( $legacy['issue_date'] ?? gmdate( 'Y-m-d' ) );
				return [
					'form'      => '6', // 6 = przelew (standardowo dla WC)
					'paid'      => $is_paid ? (float) $order->get_total() : 0.0,
					'paid_date' => $is_paid ? $paid_ymd : null,
					'due_date'  => $is_paid ? $paid_ymd : gmdate( 'Y-m-d', strtotime( '+14 days' ) ),
				];
			} )(),
		];
	}

	private function persist_outgoing(
		?\WC_Order $order,
		array $credential,
		string $session_ref,
		string $invoice_ref,
		string $xml,
		string $invoice_hash_b64,
		array $invoice_data = []
	): int {
		global $wpdb;
		$table = $wpdb->prefix . 'gksef_outgoing_invoices';

		$paid_initial = (float) ( $invoice_data['payment']['paid'] ?? 0 );
		$total_gross  = (float) ( $invoice_data['total_gross'] ?? 0 );
		$payment_status = 'unpaid';
		if ( $paid_initial >= $total_gross && $total_gross > 0 ) {
			$payment_status = 'paid';
		} elseif ( $paid_initial > 0 ) {
			$payment_status = 'partial';
		}

		$wpdb->insert(
			$table,
			[
				'credential_id'        => (int) $credential['id'],
				'wc_order_id'          => $order ? (int) $order->get_id() : 0,
				'status'               => 'sent',
				'invoice_number'       => (string) ( $invoice_data['invoice_number'] ?? '' ),
				'seller_nip'           => (string) ( $invoice_data['seller']['nip'] ?? '' ),
				'buyer_nip'            => (string) ( $invoice_data['buyer']['nip'] ?? '' ),
				'buyer_name'           => (string) ( $invoice_data['buyer']['name'] ?? '' ),
				'gross_amount'         => $total_gross,
				'net_amount'           => (float) ( $invoice_data['total_net'] ?? 0 ),
				'vat_amount'           => (float) ( $invoice_data['total_vat'] ?? 0 ),
				'currency'             => (string) ( $invoice_data['currency'] ?? 'PLN' ),
				'due_date'             => ! empty( $invoice_data['payment']['due_date'] ) ? gmdate( 'Y-m-d', strtotime( (string) $invoice_data['payment']['due_date'] ) ) : null,
				'payment_status'       => $payment_status,
				'paid_amount'          => $paid_initial,
				'notes'                => (string) ( $invoice_data['notes'] ?? '' ),
				'additional_markers'   => ! empty( $invoice_data['markers'] ) ? wp_json_encode( $invoice_data['markers'] ) : null,
				'related_docs'         => ! empty( $invoice_data['related_docs'] ) ? wp_json_encode( $invoice_data['related_docs'] ) : null,
				'extra_data'           => ! empty( $invoice_data['extra_data'] ) ? wp_json_encode( $invoice_data['extra_data'] ) : null,
				'delivery_address'     => ! empty( $invoice_data['delivery_address'] ) ? wp_json_encode( $invoice_data['delivery_address'] ) : null,
				'ksef_session_id'      => $session_ref,
				'upo_reference'        => $invoice_ref,
				'generated_xml'        => \KSEF\WordPress\Crypto::encrypt_for_storage( $xml ),
				'payload_json'         => \KSEF\WordPress\Crypto::encrypt_for_storage( wp_json_encode( $invoice_data ) ),
				'submission_timestamp' => current_time( 'mysql', true ),
				'created_by'           => get_current_user_id(),
			]
		);

		if ( $order ) {
			$order->update_meta_data( '_ksef_invoice_status', 'sent' );
			$order->update_meta_data( '_ksef_session_reference', $session_ref );
			$order->update_meta_data( '_ksef_element_reference', $invoice_ref );
			$order->update_meta_data( '_ksef_invoice_hash', $invoice_hash_b64 );
			$order->save();
		}

		$new_id = (int) $wpdb->insert_id;

		return $new_id;
	}
}
