<?php
/**
 * KSeF API v2 — Authentication Flow B (KSeF Token + RSA-OAEP).
 *
 * Sekwencja:
 *   1. POST /auth/challenge                  → { challenge, timestamp }
 *   2. encrypt "{tokenKSeF}|{timestampMs}"   RSA-OAEP-SHA256 publicznym kluczem MF (usage=KsefTokenEncryption)
 *   3. POST /auth/ksef-token                 { challenge, contextIdentifier, encryptedToken }
 *                                            → { referenceNumber, authenticationToken }
 *   4. POST /auth/token/redeem               z Authorization: Bearer authenticationToken
 *                                            → { accessToken, refreshToken } (oba w polu .token)
 *   5. Cache accessToken w transient, refresh przed expiry przez /auth/token/refresh.
 *
 * @package KSEF\WordPress\V2
 */

declare(strict_types=1);

namespace KSEF\WordPress\V2;

use KSEF\WordPress\Crypto;
use WP_Error;

class Auth {

	private const TRANSIENT_PREFIX = 'ksef_v2_token_';

	public function __construct( private Ksef_Client $client, private Public_Keys $public_keys ) {}

	/**
	 * Pełny flow autoryzacji — auto-detekcja Flow A vs Flow B.
	 *
	 * @param array<string,mixed> $credential
	 * @return array{access_token:string,access_token_expires_at:string,refresh_token:string,refresh_token_expires_at:string,reference_number:string}|WP_Error
	 */
	public function authenticate( array $credential ): array|WP_Error {
		$method = (string) ( $credential['auth_method'] ?? 'token' );
		if ( $method === 'certificate' ) {
			return $this->authenticate_flow_a( $credential );
		}
		return $this->authenticate_flow_b( $credential );
	}

	/**
	 * Flow A — XAdES signature (certyfikat kwalifikowany).
	 *
	 * @param array<string,mixed> $credential
	 * @return array<string,string>|WP_Error
	 */
	public function authenticate_flow_a( array $credential ): array|WP_Error {
		$dbg = [ 'cred_id' => (int) ( $credential['id'] ?? 0 ), 'nip' => (string) ( $credential['nip'] ?? '' ), 'env' => (string) ( $credential['ksef_environment'] ?? '' ), 'base_url' => $this->client->get_base_url() ];
		self::dbg( 'flow_a.start', $dbg );
		// 1. Challenge (auto-retry na transientne błędy sieci/DNS).
		$challenge_resp = $this->challenge_with_retry( $dbg );
		if ( is_wp_error( $challenge_resp ) ) {
			self::dbg( 'flow_a.challenge_failed', [ 'err' => $challenge_resp->get_error_message() ] + $dbg );
			return $challenge_resp;
		}
		$challenge = (string) ( $challenge_resp['challenge'] ?? '' );
		if ( $challenge === '' ) {
			return new WP_Error( 'ksef_auth_challenge_invalid', 'Brak challenge w response', $challenge_resp );
		}

		// 2. Wczytaj cert + klucz z bazy
		$cert_pem = (string) ( $credential['certificate_pem'] ?? '' );
		$key_enc  = (string) ( $credential['private_key_pem_encrypted'] ?? '' );
		if ( $cert_pem === '' || $key_enc === '' ) {
			return new WP_Error( 'ksef_no_cert_or_key', 'Brak certyfikatu lub klucza prywatnego w kredencjale.' );
		}
		try {
			$key_pem = \KSEF\WordPress\Crypto::decrypt_private_key( $key_enc );
		} catch ( \Throwable $e ) {
			return new WP_Error( 'ksef_key_decrypt', 'Nie udało się odszyfrować klucza prywatnego: ' . $e->getMessage() );
		}

		// 3. Build AuthTokenRequest XML i podpisz XAdES-BES
		$signer = new XAdES_Signer();
		$auth_xml = $signer->build_auth_token_request(
			$challenge,
			(string) $credential['nip']
		);
		$signed_xml = $signer->sign( $auth_xml, $cert_pem, $key_pem );
		if ( is_wp_error( $signed_xml ) ) {
			return $signed_xml;
		}

		// 4. POST /auth/xades-signature z raw XML — Content-Type: application/xml
		$post = $this->client->post_raw(
			'/auth/xades-signature',
			$signed_xml,
			[ 'Content-Type' => 'application/xml' ]
		);
		if ( is_wp_error( $post ) ) {
			return $post;
		}

		$reference_number     = (string) ( $post['referenceNumber'] ?? '' );
		$authentication_token = (string) ( $post['authenticationToken']['token'] ?? $post['authenticationToken'] ?? '' );
		if ( $reference_number === '' || $authentication_token === '' ) {
			self::dbg( 'flow_a.xades_response_invalid', [ 'resp' => $post ] + $dbg );
			return new WP_Error( 'ksef_xades_response_invalid', 'Brak referenceNumber/authenticationToken w response', $post );
		}
		self::dbg( 'flow_a.xades_ok', [ 'ref' => $reference_number ] + $dbg );

		return $this->poll_and_redeem( $reference_number, $authentication_token, (int) $credential['id'] );
	}

	/**
	 * Flow B — KSeF Token + RSA-OAEP-SHA256.
	 *
	 * @param array<string,mixed> $credential
	 * @return array<string,string>|WP_Error
	 */
	public function authenticate_flow_b( array $credential ): array|WP_Error {
		$debug_ctx = [
			'cred_id'  => (int) ( $credential['id'] ?? 0 ),
			'nip'      => (string) ( $credential['nip'] ?? '' ),
			'env'      => (string) ( $credential['ksef_environment'] ?? '' ),
			'base_url' => $this->client->get_base_url(),
		];
		self::dbg( 'flow_b.start', $debug_ctx );

		// 1. Challenge (z auto-retry na transientne błędy sieci/DNS — KSeF/MF bywa
		//    chwilowo nieosiągalny: "cURL error 28: Resolving timed out").
		$challenge_resp = $this->challenge_with_retry( $debug_ctx );
		if ( is_wp_error( $challenge_resp ) ) {
			self::dbg( 'flow_b.challenge_failed', [ 'err' => $challenge_resp->get_error_message() ] + $debug_ctx );
			return $challenge_resp;
		}
		$challenge     = (string) ( $challenge_resp['challenge'] ?? '' );
		$timestamp_raw = $challenge_resp['timestamp'] ?? null;
		// KSeF v2: timestamp jest ZAZWYCZAJ STRINGIEM ISO 8601 (np. "2026-05-27T19:36:14.123+00:00").
		// Wcześniejsza wersja kontraktu zwracała int milliseconds. Parsujemy oba.
		$timestamp_ms = 0;
		if ( is_string( $timestamp_raw ) && '' !== $timestamp_raw ) {
			try {
				$dt           = new \DateTimeImmutable( $timestamp_raw );
				$timestamp_ms = (int) round( ( (float) $dt->format( 'U.u' ) ) * 1000 );
			} catch ( \Throwable $e ) {
				$timestamp_ms = 0;
			}
		} elseif ( is_int( $timestamp_raw ) || is_float( $timestamp_raw ) ) {
			$timestamp_ms = (int) $timestamp_raw;
		}
		if ( $challenge === '' || $timestamp_ms === 0 ) {
			self::dbg( 'flow_b.challenge_invalid', [ 'resp' => $challenge_resp ] + $debug_ctx );
			return new WP_Error( 'ksef_auth_challenge_invalid', 'Nieprawidłowa odpowiedź /auth/challenge', $challenge_resp );
		}
		self::dbg( 'flow_b.challenge_ok', [
			'challenge_len'    => strlen( $challenge ),
			'challenge_prefix' => substr( $challenge, 0, 8 ),
			'timestamp_raw'    => $timestamp_raw,
			'timestamp_ms'     => $timestamp_ms,
			'timestamp_iso'    => gmdate( 'Y-m-d\\TH:i:s\\Z', (int) ( $timestamp_ms / 1000 ) ),
			'local_now_ms'     => (int) ( microtime( true ) * 1000 ),
			'skew_ms'          => (int) ( microtime( true ) * 1000 ) - $timestamp_ms,
		] + $debug_ctx );

		// 2. Encrypt token
		try {
			$token_plain = Crypto::decrypt_token( (string) $credential['token_encrypted'] );
		} catch ( \Throwable $e ) {
			self::dbg( 'flow_b.token_decrypt_failed', [ 'err' => $e->getMessage() ] + $debug_ctx );
			return new WP_Error( 'ksef_auth_token_decrypt', 'Nie udało się odszyfrować tokenu KSeF z bazy: ' . $e->getMessage() );
		}
		self::dbg( 'flow_b.token_decrypted', [ 'token_len' => strlen( $token_plain ), 'token_prefix' => substr( $token_plain, 0, 6 ) ] + $debug_ctx );

		$payload = $token_plain . '|' . $timestamp_ms;

		$public_key = $this->public_keys->get_active_key( 'KsefTokenEncryption' );
		if ( is_wp_error( $public_key ) ) {
			self::dbg( 'flow_b.public_key_failed', [ 'err' => $public_key->get_error_message() ] + $debug_ctx );
			return $public_key;
		}
		// SHA-256 fingerprint certyfikatu (jednoznacznie identyfikuje klucz publiczny MF)
		$cert_pem = (string) ( $public_key['certificatePem'] ?? '' );
		$cert_der = '';
		if ( $cert_pem !== '' && preg_match( '/-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----/s', $cert_pem, $m ) ) {
			$cert_der = base64_decode( preg_replace( '/\s+/', '', $m[1] ) ?? '' ) ?: ''; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- dekodowanie certyfikatu PEM do DER (uwierzytelnianie KSeF).
		}
		self::dbg( 'flow_b.public_key_ok', [
			'key_usage'      => $public_key['usage'] ?? null,
			'key_valid_from' => $public_key['validFrom'] ?? null,
			'key_valid_to'   => $public_key['validTo'] ?? null,
			'cert_sha256'    => $cert_der !== '' ? hash( 'sha256', $cert_der ) : null,
			'cert_pem_len'   => strlen( $cert_pem ),
		] + $debug_ctx );

		try {
			$encrypted     = Crypto::ksef_rsa_oaep_sha256( $payload, $public_key['certificatePem'] );
			$encrypted_b64 = base64_encode( $encrypted );
		} catch ( \Throwable $e ) {
			self::dbg( 'flow_b.encrypt_failed', [ 'err' => $e->getMessage() ] + $debug_ctx );
			return new WP_Error( 'ksef_auth_encrypt', 'RSA-OAEP encrypt failed: ' . $e->getMessage() );
		}

		// 3. Submit ksef-token
		$ksef_token_resp = $this->client->post(
			'/auth/ksef-token',
			[
				'challenge'         => $challenge,
				'contextIdentifier' => [
					'type'  => 'Nip',
					'value' => (string) $credential['nip'],
				],
				'encryptedToken'    => $encrypted_b64,
			]
		);
		if ( is_wp_error( $ksef_token_resp ) ) {
			self::dbg( 'flow_b.ksef_token_failed', [
				'err'      => $ksef_token_resp->get_error_message(),
				'err_code' => $ksef_token_resp->get_error_code(),
				'err_data' => $ksef_token_resp->get_error_data(),
			] + $debug_ctx );
			return $ksef_token_resp;
		}
		$reference_number     = (string) ( $ksef_token_resp['referenceNumber'] ?? '' );
		$authentication_token = (string) ( $ksef_token_resp['authenticationToken']['token'] ?? $ksef_token_resp['authenticationToken'] ?? '' );
		if ( $reference_number === '' || $authentication_token === '' ) {
			return new WP_Error( 'ksef_auth_response_invalid', 'Brak referenceNumber/authenticationToken w response', $ksef_token_resp );
		}

		return $this->poll_and_redeem( $reference_number, $authentication_token, (int) $credential['id'] );
	}

	/**
	 * POST /auth/challenge z automatycznym retry na transientne błędy sieci/DNS.
	 *
	 * KSeF/MF (oraz resolver DNS na hostingu) potrafią chwilowo nie odpowiadać —
	 * w logach widać "cURL error 28: Resolving timed out". Pierwszy hop (challenge)
	 * jest najbardziej podatny; jedna ponowna próba po krótkiej pauzie zwykle
	 * przechodzi. Skracamy timeout tej próby, by całość zmieściła się w limicie UI.
	 *
	 * @param array<string,mixed> $ctx kontekst do logu
	 * @return array<string,mixed>|WP_Error
	 */
	private function challenge_with_retry( array $ctx ): array|WP_Error {
		$max_attempts = 2;
		$resp         = null;
		for ( $attempt = 1; $attempt <= $max_attempts; $attempt++ ) {
			// Krótszy timeout (18s) dla samego challenge — szybciej wykryjemy zwis.
			$resp = $this->client->with_timeout( 18 )->post( '/auth/challenge' );
			// Przywróć domyślny timeout dla kolejnych żądań flow.
			$this->client->with_timeout( 30 );
			if ( ! is_wp_error( $resp ) ) {
				return $resp;
			}
			$msg = strtolower( $resp->get_error_message() );
			$transient = (
				false !== strpos( $msg, 'timed out' )
				|| false !== strpos( $msg, 'timeout' )
				|| false !== strpos( $msg, 'curl error 28' )   // operation/resolve timeout
				|| false !== strpos( $msg, 'curl error 6' )     // couldn't resolve host
				|| false !== strpos( $msg, 'curl error 7' )     // couldn't connect
				|| false !== strpos( $msg, 'resolv' )
				|| false !== strpos( $msg, 'could not resolve' )
			);
			self::dbg( 'challenge.attempt_failed', [ 'attempt' => $attempt, 'transient' => $transient, 'err' => $resp->get_error_message() ] + $ctx );
			if ( ! $transient || $attempt >= $max_attempts ) {
				return $resp;
			}
			usleep( 1_500_000 ); // 1.5s backoff przed ponowną próbą
		}
		return $resp;
	}

	/**
	 * Wspólny helper dla Flow A i Flow B: poll status → redeem → zwróć tokeny i cache.
	 *
	 * @return array{access_token:string,access_token_expires_at:string,refresh_token:string,refresh_token_expires_at:string,reference_number:string}|WP_Error
	 */
	private function poll_and_redeem( string $reference_number, string $authentication_token, int $credential_id ): array|WP_Error {
		// Poll status (max 30s) — wymaga Bearer authenticationToken
		$start = time();
		$poll_result = null;
		while ( time() - $start < 30 ) {
			usleep( 800_000 );
			$status_resp = $this->client->with_bearer( $authentication_token )->get( "/auth/{$reference_number}" );
			if ( is_wp_error( $status_resp ) ) {
				return $status_resp;
			}
			// status.code: 100=InProgress, 200=Successful, >=400 błąd
			$code = (int) ( $status_resp['status']['code'] ?? 0 );
			if ( $code === 200 ) {
				$poll_result = $status_resp;
				break;
			}
			if ( $code >= 400 ) {
				return new WP_Error(
					'ksef_auth_rejected',
					(string) ( $status_resp['status']['description'] ?? 'Autoryzacja odrzucona przez KSeF' ),
					$status_resp
				);
			}
		}
		if ( $poll_result === null ) {
			self::dbg( 'poll.timeout', [ 'ref' => $reference_number ] );
			return new WP_Error( 'ksef_auth_timeout', 'Autoryzacja KSeF nie zakończona w 30s' );
		}
		self::dbg( 'poll.ok', [ 'ref' => $reference_number ] );

		// Redeem
		$redeem = $this->client->with_bearer( $authentication_token )->post( '/auth/token/redeem' );
		if ( is_wp_error( $redeem ) ) {
			self::dbg( 'redeem.failed', [ 'err' => $redeem->get_error_message() ] );
			return $redeem;
		}
		$access  = $redeem['accessToken'] ?? [];
		$refresh = $redeem['refreshToken'] ?? [];

		$result = [
			'access_token'             => (string) ( $access['token'] ?? '' ),
			'access_token_expires_at'  => (string) ( $access['validUntil'] ?? '' ),
			'refresh_token'            => (string) ( $refresh['token'] ?? '' ),
			'refresh_token_expires_at' => (string) ( $refresh['validUntil'] ?? '' ),
			'reference_number'         => $reference_number,
		];
		if ( $result['access_token'] === '' ) {
			return new WP_Error( 'ksef_auth_redeem_empty', 'Brak accessToken w response /auth/token/redeem', $redeem );
		}

		$ttl = max( 60, strtotime( $result['access_token_expires_at'] ) - time() - 60 );
		self::cache_put( (int) $credential_id, $result, (int) $ttl );

		return $result;
	}

	/**
	 * Cache tokenów KSeF w transiencie — ZASZYFROWANY (AES-256-GCM). Transient (wp_options)
	 * trzyma tylko szyfrogram; nawet dump bazy nie ujawni access/refresh tokenu.
	 */
	private static function cache_put( int $id, array $result, int $ttl ): void {
		set_transient( self::TRANSIENT_PREFIX . $id, \KSEF\WordPress\Crypto::encrypt_for_storage( (string) wp_json_encode( $result ) ), $ttl );
	}

	private static function cache_get( int $id ) {
		$raw = get_transient( self::TRANSIENT_PREFIX . $id );
		if ( is_array( $raw ) ) {
			return $raw; // legacy: stary transient (plaintext array) — nadal działa
		}
		if ( is_string( $raw ) && $raw !== '' ) {
			$arr = json_decode( (string) \KSEF\WordPress\Crypto::decrypt_from_storage( $raw ), true );
			return is_array( $arr ) ? $arr : null;
		}
		return null;
	}

	/**
	 * Zwraca ważny access_token — z cache, ewentualnie refresh, ewentualnie pełen authenticate.
	 *
	 * @param array{id:int,nip:string,token_encrypted:string,ksef_environment:string} $credential
	 * @return string|WP_Error
	 */
	public function get_access_token( array $credential ): string|WP_Error {
		$cached = self::cache_get( (int) $credential['id'] );
		if ( is_array( $cached ) && ! empty( $cached['access_token'] ) ) {
			$expires_at = (int) strtotime( (string) $cached['access_token_expires_at'] );
			if ( $expires_at > time() + 30 ) {
				return (string) $cached['access_token'];
			}
			// Spróbuj refresh
			if ( ! empty( $cached['refresh_token'] ) && (int) strtotime( (string) $cached['refresh_token_expires_at'] ) > time() ) {
				$refreshed = $this->refresh( (string) $cached['refresh_token'], (int) $credential['id'] );
				if ( ! is_wp_error( $refreshed ) ) {
					return $refreshed['access_token'];
				}
			}
		}
		$fresh = $this->authenticate( $credential );
		if ( is_wp_error( $fresh ) ) {
			return $fresh;
		}
		return $fresh['access_token'];
	}

	/**
	 * @return array{access_token:string,access_token_expires_at:string,refresh_token:string,refresh_token_expires_at:string}|WP_Error
	 */
	public function refresh( string $refresh_token, int $credential_id ) {
		$resp = $this->client->with_bearer( $refresh_token )->post( '/auth/token/refresh' );
		if ( is_wp_error( $resp ) ) {
			return $resp;
		}
		$access  = $resp['accessToken'] ?? [];
		$refresh = $resp['refreshToken'] ?? [];
		$result  = [
			'access_token'             => (string) ( $access['token'] ?? '' ),
			'access_token_expires_at'  => (string) ( $access['validUntil'] ?? '' ),
			'refresh_token'            => (string) ( $refresh['token'] ?? $refresh_token ),
			'refresh_token_expires_at' => (string) ( $refresh['validUntil'] ?? '' ),
		];
		if ( $result['access_token'] === '' ) {
			return new WP_Error( 'ksef_refresh_empty', 'Brak accessToken w /auth/token/refresh', $resp );
		}
		$ttl = max( 60, strtotime( $result['access_token_expires_at'] ) - time() - 60 );
		self::cache_put( (int) $credential_id, $result, (int) $ttl );
		return $result;
	}

	/**
	 * Zakończ sesję autoryzacyjną (np. przy deaktywacji kredencjału w panelu admin).
	 */
	public function terminate( string $access_token, int $credential_id ): void {
		$this->client->with_bearer( $access_token )->delete( '/auth/sessions' );
		delete_transient( self::TRANSIENT_PREFIX . $credential_id );
	}

	/**
	 * Debug-logging dla auth flow.
	 *
	 * Zapisuje do `wp_options.ksef_auth_debug_log` (ring buffer ostatnich 50 wpisów)
	 * + do error_log() jeśli WP_DEBUG_LOG jest włączone.
	 *
	 * @param string $event Krótki tag (np. 'flow_b.challenge_ok')
	 * @param array<string,mixed> $ctx Kontekstowy słownik
	 */
	private static function dbg( string $event, array $ctx ): void {
		$line = [
			'ts'    => gmdate( 'Y-m-d\\TH:i:s\\Z' ),
			'event' => $event,
			'ctx'   => $ctx,
		];
		// error_log
		if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			ksef_efaktury_log( '[KSEF-AUTH] ' . wp_json_encode( $line ) );
		}
		// Ring buffer w wp_options (max 50 ostatnich wpisów — admin może podejrzeć)
		$log = (array) get_option( 'ksef_auth_debug_log', [] );
		$log[] = $line;
		if ( count( $log ) > 50 ) {
			$log = array_slice( $log, -50 );
		}
		update_option( 'ksef_auth_debug_log', $log, false );
	}
}
