<?php
/**
 * XAdES-BES enveloped signer dla AuthTokenRequest KSeF v2.
 *
 * Specyfikacja MF (ksef-docs/flow/podpis-xades.md + _SUMMARY_auth.md):
 *   - Format:       enveloped
 *   - Profil:       XAdES-BES (Basic Electronic Signature)
 *   - C14N:         http://www.w3.org/2001/10/xml-exc-c14n#
 *   - SignatureMethod: http://www.w3.org/2001/04/xmldsig-more#rsa-sha256
 *   - DigestMethod:    http://www.w3.org/2001/04/xmlenc#sha256
 *   - Min. klucz:   RSA 2048
 *
 * Implementacja w czystym PHP (DOMDocument + openssl_sign), bez phpseclib.
 *
 * @package KSEF\WordPress\V2
 */

declare(strict_types=1);

namespace KSEF\WordPress\V2;

use WP_Error;

final class XAdES_Signer {

	private const NS_DS    = 'http://www.w3.org/2000/09/xmldsig#';
	private const NS_XADES = 'http://uri.etsi.org/01903/v1.3.2#';
	private const NS_AUTH  = 'http://ksef.mf.gov.pl/auth/token/2.0';

	private const C14N_ALG      = 'http://www.w3.org/2001/10/xml-exc-c14n#';
	private const SIG_ALG_RSA   = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
	private const SIG_ALG_ECDSA = 'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256';
	private const DIGEST_ALG    = 'http://www.w3.org/2001/04/xmlenc#sha256';
	private const ENVELOPED     = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature';

	/**
	 * Zbuduj AuthTokenRequest XML (bez podpisu — gotowy do `sign()`).
	 *
	 * @param string $challenge       Z `/auth/challenge` response
	 * @param string $nip             10-cyfrowy NIP podmiotu
	 * @param string $subject_type    'certificateSubject' lub 'certificateFingerprint'
	 */
	public function build_auth_token_request( string $challenge, string $nip, string $subject_type = 'certificateSubject' ): string {
		$dom               = new \DOMDocument( '1.0', 'UTF-8' );
		$dom->formatOutput = false;
		$dom->preserveWhiteSpace = false;

		$req = $dom->createElementNS( self::NS_AUTH, 'AuthTokenRequest' );
		$dom->appendChild( $req );

		$req->appendChild( $dom->createElementNS( self::NS_AUTH, 'Challenge', $challenge ) );

		$ci = $dom->createElementNS( self::NS_AUTH, 'ContextIdentifier' );
		$req->appendChild( $ci );
		$ci->appendChild( $dom->createElementNS( self::NS_AUTH, 'Nip', $nip ) );

		$req->appendChild( $dom->createElementNS( self::NS_AUTH, 'SubjectIdentifierType', $subject_type ) );

		return (string) $dom->saveXML();
	}

	/**
	 * Podpisz XML XAdES-BES enveloped. Zwraca XML z dołączonym `<ds:Signature>`.
	 *
	 * Algorytm:
	 *   1. Załaduj XML do DOM, znajdź root.
	 *   2. Wczytaj cert + klucz prywatny przez openssl.
	 *   3. Wygeneruj wszystkie elementy Signature z placeholderami w DigestValue x2 i SignatureValue.
	 *   4. Oblicz Reference[1] digest:
	 *      - Sklonuj root, usuń z niego ds:Signature (enveloped transform),
	 *      - C14N exclusive,
	 *      - SHA-256, base64 → wstaw do DigestValue[1].
	 *   5. Oblicz Reference[2] digest:
	 *      - C14N exclusive SignedProperties,
	 *      - SHA-256, base64 → wstaw do DigestValue[2].
	 *   6. Oblicz SignatureValue:
	 *      - C14N exclusive SignedInfo,
	 *      - openssl_sign RSA-SHA256, base64 → wstaw do SignatureValue.
	 *   7. Zwróć XML.
	 *
	 * @param string $xml             XML payload (bez podpisu)
	 * @param string $cert_pem        Certyfikat X.509 w formacie PEM
	 * @param string $private_key_pem Klucz prywatny RSA w formacie PEM (deszyfrowany)
	 * @return string|WP_Error
	 */
	public function sign( string $xml, string $cert_pem, string $private_key_pem ): string|WP_Error {
		$dom = new \DOMDocument();
		$dom->preserveWhiteSpace = true;
		$dom->formatOutput       = false;
		if ( ! $dom->loadXML( $xml, LIBXML_NOBLANKS ) ) {
			return new WP_Error( 'xades_xml_invalid', 'Nieprawidłowy XML wejściowy.' );
		}

		$key = openssl_pkey_get_private( $private_key_pem );
		if ( $key === false ) {
			return new WP_Error( 'xades_key_invalid', 'Nieprawidłowy klucz prywatny: ' . (string) openssl_error_string() );
		}
		$details = openssl_pkey_get_details( $key );
		if ( ! is_array( $details ) ) {
			return new WP_Error( 'xades_key_details', 'Nie udało się odczytać klucza prywatnego.' );
		}
		$key_type = (int) ( $details['type'] ?? OPENSSL_KEYTYPE_RSA );
		$is_ecdsa = $key_type === OPENSSL_KEYTYPE_EC;
		$sig_alg_uri = $is_ecdsa ? self::SIG_ALG_ECDSA : self::SIG_ALG_RSA;
		// Rozmiar koordynaty EC (P-256 → 32B, P-384 → 48B, P-521 → 66B)
		$ec_field_size = 32;
		if ( $is_ecdsa && isset( $details['ec']['curve_name'] ) ) {
			$ec_field_size = match ( $details['ec']['curve_name'] ) {
				'prime256v1', 'secp256r1' => 32,
				'secp384r1'               => 48,
				'secp521r1'               => 66,
				default                   => 32,
			};
		}

		$cert_info = openssl_x509_parse( $cert_pem );
		if ( $cert_info === false ) {
			return new WP_Error( 'xades_cert_invalid', 'Nieprawidłowy certyfikat.' );
		}

		// Bajty DER certyfikatu (potrzebne do CertDigest + X509Certificate)
		$cert_der_b64 = $this->pem_to_der_base64( $cert_pem );
		if ( $cert_der_b64 === '' ) {
			return new WP_Error( 'xades_cert_der', 'Nie udało się przekonwertować certyfikatu na DER.' );
		}
		$cert_der    = base64_decode( $cert_der_b64, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- dekodowanie certyfikatu DER (podpis XAdES).
		$cert_digest = base64_encode( hash( 'sha256', (string) $cert_der, true ) );

		$issuer_dn = $this->dn_to_rfc2253( (array) ( $cert_info['issuer'] ?? [] ) );
		$serial    = (string) ( $cert_info['serialNumber'] ?? '0' );

		$root = $dom->documentElement;
		if ( $root === null ) {
			return new WP_Error( 'xades_no_root', 'Brak root w XML.' );
		}

		// Zbuduj strukturę Signature
		$signature = $dom->createElementNS( self::NS_DS, 'ds:Signature' );
		$signature->setAttribute( 'Id', 'Signature' );
		$root->appendChild( $signature );

		// SignedInfo
		$signed_info = $dom->createElementNS( self::NS_DS, 'ds:SignedInfo' );
		$signature->appendChild( $signed_info );

		$c14n_method = $dom->createElementNS( self::NS_DS, 'ds:CanonicalizationMethod' );
		$c14n_method->setAttribute( 'Algorithm', self::C14N_ALG );
		$signed_info->appendChild( $c14n_method );

		$sig_method = $dom->createElementNS( self::NS_DS, 'ds:SignatureMethod' );
		$sig_method->setAttribute( 'Algorithm', $sig_alg_uri );
		$signed_info->appendChild( $sig_method );

		// Reference 1: enveloped do całego dokumentu (URI="")
		$ref_root = $dom->createElementNS( self::NS_DS, 'ds:Reference' );
		$ref_root->setAttribute( 'URI', '' );
		$signed_info->appendChild( $ref_root );

		$ref_root_transforms = $dom->createElementNS( self::NS_DS, 'ds:Transforms' );
		$ref_root->appendChild( $ref_root_transforms );

		$t_env = $dom->createElementNS( self::NS_DS, 'ds:Transform' );
		$t_env->setAttribute( 'Algorithm', self::ENVELOPED );
		$ref_root_transforms->appendChild( $t_env );

		$t_c14n = $dom->createElementNS( self::NS_DS, 'ds:Transform' );
		$t_c14n->setAttribute( 'Algorithm', self::C14N_ALG );
		$ref_root_transforms->appendChild( $t_c14n );

		$ref_root_dm = $dom->createElementNS( self::NS_DS, 'ds:DigestMethod' );
		$ref_root_dm->setAttribute( 'Algorithm', self::DIGEST_ALG );
		$ref_root->appendChild( $ref_root_dm );

		$ref_root_dv = $dom->createElementNS( self::NS_DS, 'ds:DigestValue', 'PLACEHOLDER' );
		$ref_root->appendChild( $ref_root_dv );

		// Reference 2: SignedProperties (#SignedProperties)
		$ref_sp = $dom->createElementNS( self::NS_DS, 'ds:Reference' );
		$ref_sp->setAttribute( 'Type', 'http://uri.etsi.org/01903#SignedProperties' );
		$ref_sp->setAttribute( 'URI', '#SignedProperties' );
		$signed_info->appendChild( $ref_sp );

		// Explicit Transforms: exclusive-c14n (żeby MF używał tej samej C14N co my)
		$ref_sp_transforms = $dom->createElementNS( self::NS_DS, 'ds:Transforms' );
		$ref_sp->appendChild( $ref_sp_transforms );
		$ref_sp_t = $dom->createElementNS( self::NS_DS, 'ds:Transform' );
		$ref_sp_t->setAttribute( 'Algorithm', self::C14N_ALG );
		$ref_sp_transforms->appendChild( $ref_sp_t );

		$ref_sp_dm = $dom->createElementNS( self::NS_DS, 'ds:DigestMethod' );
		$ref_sp_dm->setAttribute( 'Algorithm', self::DIGEST_ALG );
		$ref_sp->appendChild( $ref_sp_dm );

		$ref_sp_dv = $dom->createElementNS( self::NS_DS, 'ds:DigestValue', 'PLACEHOLDER' );
		$ref_sp->appendChild( $ref_sp_dv );

		// SignatureValue
		$sig_value = $dom->createElementNS( self::NS_DS, 'ds:SignatureValue', 'PLACEHOLDER' );
		$signature->appendChild( $sig_value );

		// KeyInfo
		$key_info = $dom->createElementNS( self::NS_DS, 'ds:KeyInfo' );
		$signature->appendChild( $key_info );
		$x509_data = $dom->createElementNS( self::NS_DS, 'ds:X509Data' );
		$key_info->appendChild( $x509_data );
		$x509_data->appendChild( $dom->createElementNS( self::NS_DS, 'ds:X509Certificate', $cert_der_b64 ) );

		// Object/QualifyingProperties/SignedProperties
		$object = $dom->createElementNS( self::NS_DS, 'ds:Object' );
		$signature->appendChild( $object );

		$qp = $dom->createElementNS( self::NS_XADES, 'xades:QualifyingProperties' );
		$qp->setAttribute( 'Target', '#Signature' );
		$object->appendChild( $qp );

		$sp = $dom->createElementNS( self::NS_XADES, 'xades:SignedProperties' );
		$sp->setAttribute( 'Id', 'SignedProperties' );
		$qp->appendChild( $sp );

		$ssp = $dom->createElementNS( self::NS_XADES, 'xades:SignedSignatureProperties' );
		$sp->appendChild( $ssp );

		$ssp->appendChild( $dom->createElementNS( self::NS_XADES, 'xades:SigningTime', gmdate( 'Y-m-d\TH:i:s\Z' ) ) );

		$signing_cert = $dom->createElementNS( self::NS_XADES, 'xades:SigningCertificate' );
		$ssp->appendChild( $signing_cert );

		$cert_el = $dom->createElementNS( self::NS_XADES, 'xades:Cert' );
		$signing_cert->appendChild( $cert_el );

		$cd = $dom->createElementNS( self::NS_XADES, 'xades:CertDigest' );
		$cert_el->appendChild( $cd );
		$cd_dm = $dom->createElementNS( self::NS_DS, 'ds:DigestMethod' );
		$cd_dm->setAttribute( 'Algorithm', self::DIGEST_ALG );
		$cd->appendChild( $cd_dm );
		$cd->appendChild( $dom->createElementNS( self::NS_DS, 'ds:DigestValue', $cert_digest ) );

		$is = $dom->createElementNS( self::NS_XADES, 'xades:IssuerSerial' );
		$cert_el->appendChild( $is );
		$is->appendChild( $dom->createElementNS( self::NS_DS, 'ds:X509IssuerName', $issuer_dn ) );
		$is->appendChild( $dom->createElementNS( self::NS_DS, 'ds:X509SerialNumber', $serial ) );

		// === Krok 4: Reference[1] digest (enveloped: root bez ds:Signature) ===
		$tmp = new \DOMDocument();
		$tmp->loadXML( $xml, LIBXML_NOBLANKS );
		$root_clone = $tmp->documentElement;
		$ref_root_canon = $root_clone->C14N( true, false );
		$ref_root_dv->nodeValue = base64_encode( hash( 'sha256', $ref_root_canon, true ) );

		// === Krok 5: Reference[2] digest (SignedProperties) — czysty exclusive C14N ===
		$ref_sp_canon       = $sp->C14N( true, false );
		$ref_sp_dv->nodeValue = base64_encode( hash( 'sha256', $ref_sp_canon, true ) );

		// === Krok 6: SignatureValue z C14N SignedInfo ===
		$si_canon = $signed_info->C14N( true, false, null, [ 'ds' ] );
		$signature_bin = '';
		if ( ! openssl_sign( $si_canon, $signature_bin, $key, OPENSSL_ALGO_SHA256 ) ) {
			return new WP_Error( 'xades_sign_failed', 'openssl_sign failed: ' . (string) openssl_error_string() );
		}
		// Dla ECDSA: openssl_sign zwraca DER-encoded ASN.1 SEQUENCE { r, s }.
		// XML Signature wymaga raw konkatenacji r || s (każde field_size bajtów).
		if ( $is_ecdsa ) {
			$signature_bin = $this->ecdsa_der_to_raw( $signature_bin, $ec_field_size );
			if ( $signature_bin === '' ) {
				return new WP_Error( 'xades_ecdsa_decode', 'Nie udało się przekonwertować podpisu ECDSA DER→raw.' );
			}
		}
		$sig_value->nodeValue = base64_encode( $signature_bin );

		return (string) $dom->saveXML();
	}

	/**
	 * Konwersja podpisu ECDSA z formatu ASN.1 DER (openssl_sign) na raw `r || s` (XMLDSIG).
	 * Każda koordynata padded do `$field_size` bajtów po lewej zerami.
	 */
	private function ecdsa_der_to_raw( string $der, int $field_size ): string {
		$offset = 0;
		if ( ! isset( $der[ $offset ] ) || ord( $der[ $offset++ ] ) !== 0x30 ) {
			return '';
		}
		// Długość SEQUENCE
		$len = ord( $der[ $offset++ ] );
		if ( $len > 0x80 ) {
			$bytes = $len - 0x80;
			$len = 0;
			for ( $i = 0; $i < $bytes; $i++ ) {
				$len = ( $len << 8 ) | ord( $der[ $offset++ ] );
			}
		}
		// INTEGER r
		if ( ord( $der[ $offset++ ] ) !== 0x02 ) return '';
		$r_len = ord( $der[ $offset++ ] );
		if ( $r_len > 0x80 ) {
			$bytes = $r_len - 0x80;
			$r_len = 0;
			for ( $i = 0; $i < $bytes; $i++ ) {
				$r_len = ( $r_len << 8 ) | ord( $der[ $offset++ ] );
			}
		}
		$r = substr( $der, $offset, $r_len );
		$offset += $r_len;
		// INTEGER s
		if ( ord( $der[ $offset++ ] ) !== 0x02 ) return '';
		$s_len = ord( $der[ $offset++ ] );
		if ( $s_len > 0x80 ) {
			$bytes = $s_len - 0x80;
			$s_len = 0;
			for ( $i = 0; $i < $bytes; $i++ ) {
				$s_len = ( $s_len << 8 ) | ord( $der[ $offset++ ] );
			}
		}
		$s = substr( $der, $offset, $s_len );
		// Strip leading 0x00 padding (ASN.1 INTEGER sign bit)
		$r = ltrim( $r, "\x00" );
		$s = ltrim( $s, "\x00" );
		// Pad na field_size
		$r = str_pad( $r, $field_size, "\x00", STR_PAD_LEFT );
		$s = str_pad( $s, $field_size, "\x00", STR_PAD_LEFT );
		return $r . $s;
	}

	/**
	 * Konwertuje certyfikat PEM na surowy base64 (bez nagłówków).
	 */
	private function pem_to_der_base64( string $pem ): string {
		if ( ! preg_match( '/-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----/s', $pem, $m ) ) {
			return '';
		}
		return (string) preg_replace( '/\s+/', '', $m[1] );
	}

	/**
	 * Konwertuje tablicę DN z openssl_x509_parse na string RFC 2253.
	 * Format MF: kolejność attribut jak openssl je zwraca, łączone ", ".
	 *
	 * @param array<string,string|array<int,string>> $dn
	 */
	private function dn_to_rfc2253( array $dn ): string {
		$parts = [];
		foreach ( $dn as $key => $value ) {
			if ( is_array( $value ) ) {
				foreach ( $value as $v ) {
					$parts[] = $key . '=' . $this->escape_dn_value( (string) $v );
				}
			} else {
				$parts[] = $key . '=' . $this->escape_dn_value( (string) $value );
			}
		}
		// MF typowo używa "CN=...,O=...,C=PL" — separator ","
		return implode( ',', $parts );
	}

	private function escape_dn_value( string $v ): string {
		// Escape RFC 2253: ,+"\<>;
		return (string) preg_replace( '/([,+"\\\\<>;])/', '\\\\$1', $v );
	}
}
