<?php

declare( strict_types=1 );

namespace Brightfern\Invoice;

use DOMDocument;
use DOMElement;
use Brightfern\Invoice\Model\Address;
use Brightfern\Invoice\Model\Invoice;
use Brightfern\Invoice\Model\InvoiceLine;
use Brightfern\Invoice\Model\Party;
use Brightfern\Invoice\Model\TaxSubtotal;
use Brightfern\Support\Num;

/**
 * Serializes the internal {@see Invoice} model to Poland's FA(3) structured
 * invoice — the format the national KSeF clearance system accepts, mandatory
 * since 1 February 2026 (large taxpayers) / 1 April 2026 (everyone else).
 *
 * This is NOT another binding of EN 16931. UBL (Peppol/XRechnung) and CII
 * (Factur-X) are two syntaxes over one semantic model; FA(3) is a separate
 * national model written against the Polish VAT act (art. 106a-106q), with its
 * own numbered fields (P_1, P_2, P_13_x …). So there is no shared structure to
 * reuse from the other serializers — only the syntax-neutral {@see Invoice}
 * model, which is exactly what it was built for.
 *
 * Structure verified field-by-field against the Ministry of Finance's own
 * published XSD (CIRFMF/ksef-docs, schemat_FA(3)_v1-0E.xsd) rather than against
 * secondary documentation, and the output is XSD-validated in
 * tests/Invoice/Fa3SerializerTest.php against that same bundled schema.
 *
 * Two traps that cost real debugging time, recorded so they are not re-learned:
 *
 *  1. FA(3) defines its OWN `TAdres` — country code plus one or two free-text
 *     address lines. The shared Ministry base library (StrukturyDanych) also
 *     defines a `TAdres`, a far heavier type demanding wojewodztwo/powiat/gmina.
 *     FA(3) does not use it. Resolving the name against the wrong file makes it
 *     look as though every buyer needs Polish administrative subdivisions.
 *  2. Element ORDER is significant throughout (xsd:sequence), including the
 *     correction block, which sits between RodzajFaktury and FaWiersz — not at
 *     the end where it reads more naturally.
 *
 * SCOPE FOR v1 — deliberately bounded, see docs/DECISIONS.md:
 *  - Generates the document; it does NOT transmit to KSeF. KSeF is a clearance
 *    system (an invoice is only legally issued once KSeF assigns it a number),
 *    but the free Ministry "Aplikacja Podatnika" accepts a hand-uploaded FA(3)
 *    file, so generate-and-upload is a complete workflow on its own — the same
 *    shape as Factur-X, where the merchant submits the file themselves.
 *  - Correction invoices (KOR) are emitted as a "korekta in minus": the refund
 *    is expressed as negative amounts. FA(3) also supports a richer before/after
 *    representation (the StanPrzed line flag) that v1 does not produce.
 *  - Foreign-currency invoices omit the PLN tax restatement (P_14_xW), which
 *    needs an NBP exchange rate Brightfern does not fetch. InvoiceValidator
 *    warns when the currency is not PLN.
 */
final class Fa3Serializer {

	/** Target namespace of the FA(3) form template, per the Ministry's XSD. */
	private const NS = 'http://crd.gov.pl/wzor/2025/06/25/13775/';

	/** Fixed header values the XSD pins with `fixed=` attributes. */
	public const FORM_CODE     = 'FA';
	public const SYSTEM_CODE   = 'FA (3)';
	public const SCHEMA_VERSION = '1-0E';
	public const VARIANT       = '3';

	/** RodzajFaktury values we emit (the enum also covers advance/settlement invoices). */
	public const KIND_INVOICE   = 'VAT';
	public const KIND_CORRECTION = 'KOR';

	/**
	 * Field length caps from the schema's own simple types. FA(3) enforces these
	 * with maxLength facets, so an over-long value is not truncated politely by
	 * KSeF — the whole document is rejected. WooCommerce happily produces values
	 * that exceed them: SKUs are free text (TZnakowy50), the GTIN slot is only
	 * 20 characters, and long variable-product titles run past 512.
	 */
	private const MAX_TEXT   = 240; // TZnakowy
	private const MAX_TEXT50 = 50;  // TZnakowy50
	private const MAX_TEXT512 = 512; // TZnakowy512
	private const MAX_GTIN   = 20;  // TZnakowy20

	/**
	 * EU VAT country prefixes accepted by FA(3) for a buyer's `KodUE`. Matches
	 * the schema's TKodyKrajowUE enumeration exactly (EL for Greece, XI for
	 * Northern Ireland — the VAT-world codes, not plain ISO 3166), and is the
	 * same canonical list {@see \Brightfern\Validation\PartyValidator} uses for
	 * VIES lookups — see {@see Address::EU_VAT_PREFIXES} for why this must stay
	 * one shared list rather than two hand-copied ones.
	 */
	private const EU_VAT_PREFIXES = Address::EU_VAT_PREFIXES;

	/**
	 * Maps an EN 16931 VAT category + rate onto the Polish statutory rate label
	 * (P_12 on a line) and the header total buckets it accumulates into.
	 *
	 * The buckets are fixed by the VAT act, so a rate that has no Polish
	 * equivalent (a 21% Dutch rate, say) genuinely cannot be expressed — that
	 * is a real constraint of the format, not a gap here, and it is why
	 * {@see serialize()} refuses rather than silently rounding into a bucket.
	 *
	 * Public so {@see \Brightfern\Validation\InvoiceValidator} can ask whether a
	 * rate is expressible without duplicating this table — the pre-flight gate
	 * should tell the merchant before generation fails, not after.
	 *
	 * @return array{0:string,1:string,2:?string} [P_12 label, net bucket, VAT bucket]
	 */
	public static function rateBucket( string $category, float $percent ): ?array {
		if ( VatCategory::STANDARD === $category ) {
			return match ( true ) {
				Num::moneyEquals( $percent, 23.0 ) => array( '23', 'P_13_1', 'P_14_1' ),
				Num::moneyEquals( $percent, 22.0 ) => array( '22', 'P_13_1', 'P_14_1' ),
				Num::moneyEquals( $percent, 8.0 )  => array( '8', 'P_13_2', 'P_14_2' ),
				Num::moneyEquals( $percent, 7.0 )  => array( '7', 'P_13_2', 'P_14_2' ),
				Num::moneyEquals( $percent, 5.0 )  => array( '5', 'P_13_3', 'P_14_3' ),
				Num::moneyEquals( $percent, 4.0 )  => array( '4', 'P_13_4', 'P_14_4' ),
				Num::moneyEquals( $percent, 3.0 )  => array( '3', 'P_13_4', 'P_14_4' ),
				default                            => null,
			};
		}

		return match ( $category ) {
			// "0 KR" is domestic zero-rating; WDT and export get their own labels
			// and their own buckets, because the VAT return separates them.
			VatCategory::ZERO            => array( '0 KR', 'P_13_6_1', null ),
			VatCategory::INTRA_COMMUNITY => array( '0 WDT', 'P_13_6_2', null ),
			VatCategory::EXPORT          => array( '0 EX', 'P_13_6_3', null ),
			VatCategory::EXEMPT          => array( 'zw', 'P_13_7', null ),
			VatCategory::REVERSE_CHARGE  => array( 'oo', 'P_13_10', null ),
			// "np I" is supply outside Poland generally; "np II" is specifically
			// the art. 100(1)(4) services. Out-of-scope maps to the general case.
			VatCategory::OUT_OF_SCOPE    => array( 'np I', 'P_13_8', null ),
			default                      => null,
		};
	}

	/** Header buckets in the exact order the XSD's sequence demands. */
	private const BUCKET_ORDER = array(
		'P_13_1', 'P_14_1',
		'P_13_2', 'P_14_2',
		'P_13_3', 'P_14_3',
		'P_13_4', 'P_14_4',
		'P_13_5',
		'P_13_6_1', 'P_13_6_2', 'P_13_6_3',
		'P_13_7', 'P_13_8', 'P_13_9', 'P_13_10', 'P_13_11',
	);

	private DOMDocument $doc;

	/**
	 * @throws \RuntimeException When the invoice cannot be expressed in FA(3):
	 *   a seller without a valid Polish NIP, or a VAT rate with no Polish
	 *   statutory equivalent. Both are caught earlier by InvoiceValidator so the
	 *   merchant sees a pre-flight finding rather than a generation failure.
	 */
	public function serialize( Invoice $invoice ): string {
		$this->doc                     = new DOMDocument( '1.0', 'UTF-8' );
		$this->doc->formatOutput       = true;
		$this->doc->preserveWhiteSpace = false;

		// Default namespace, no prefix: elementFormDefault="qualified" puts every
		// descendant in the same namespace, which is how the Ministry's samples read.
		$root = $this->doc->createElementNS( self::NS, 'Faktura' );
		$this->doc->appendChild( $root );

		$this->buildNaglowek( $root );
		$this->buildPodmiot1( $root, $invoice->seller );
		$this->buildPodmiot2( $root, $invoice->buyer );
		$this->buildFa( $root, $invoice );

		return (string) $this->doc->saveXML();
	}

	/* ------------------------------------------------------------------ *
	 * Header and parties
	 * ------------------------------------------------------------------ */

	private function buildNaglowek( DOMElement $root ): void {
		$head = $this->el( $root, 'Naglowek' );

		$code = $this->el( $head, 'KodFormularza', self::FORM_CODE );
		$code->setAttribute( 'kodSystemowy', self::SYSTEM_CODE );
		$code->setAttribute( 'wersjaSchemy', self::SCHEMA_VERSION );

		$this->el( $head, 'WariantFormularza', self::VARIANT );
		// The schema pins this to a UTC instant no earlier than 2025-09-01.
		$this->el( $head, 'DataWytworzeniaFa', gmdate( 'Y-m-d\TH:i:s\Z' ) );
	}

	private function buildPodmiot1( DOMElement $root, Party $seller ): void {
		$node = $this->el( $root, 'Podmiot1' );

		$ident = $this->el( $node, 'DaneIdentyfikacyjne' );
		$this->el( $ident, 'NIP', $this->requireNip( $seller ) );
		$this->el( $ident, 'Nazwa', self::clamp( $seller->registrationName, self::MAX_TEXT512 ) );

		// Podmiot1/Adres is mandatory, so unlike the buyer this cannot be skipped.
		if ( ! $seller->address ) {
			throw new \RuntimeException( 'KSeF requires a seller address: complete the address in Brightfern settings.' );
		}
		$this->buildAdres( $node, $seller );
	}

	private function buildPodmiot2( DOMElement $root, Party $buyer ): void {
		$node = $this->el( $root, 'Podmiot2' );

		// Exactly one identification branch, in the schema's choice order.
		$ident   = $this->el( $node, 'DaneIdentyfikacyjne' );
		$vat     = self::digitsAndLetters( $buyer->vatNumber );
		$country = $buyer->vatCountry();
		$nip     = 'PL' === $country ? self::polishNip( $buyer->vatNumber ) : null;

		// The EU branch needs the number AFTER the country prefix; a VAT field
		// holding just "PL" leaves nothing behind it, and NrVatUE has minLength 1,
		// so that branch would emit an empty mandatory element. Fall through to
		// the no-identifier flag instead, which is what we actually know.
		$euNumber = in_array( $country, self::EU_VAT_PREFIXES, true ) ? substr( $vat, 2, 12 ) : '';

		if ( null !== $nip ) {
			$this->el( $ident, 'NIP', $nip );
		} elseif ( '' !== $euNumber ) {
			$this->el( $ident, 'KodUE', $country );
			$this->el( $ident, 'NrVatUE', $euNumber );
		} elseif ( '' !== $buyer->companyId ) {
			// NrID's schema type is TNrIdentyfikacjiPodatkowej (maxLength 50), NOT
			// the generic TZnakowy (240) — a foreign tax id over 50 chars produces
			// schema-invalid, KSeF-rejected XML rather than a politely truncated one.
			$this->el( $ident, 'NrID', self::clamp( $buyer->companyId, self::MAX_TEXT50 ) );
		} else {
			// Consumers, and any buyer whose id we cannot classify: the schema has
			// an explicit "no tax identifier" flag for exactly this case.
			$this->el( $ident, 'BrakID', '1' );
		}

		if ( '' !== $buyer->registrationName ) {
			$this->el( $ident, 'Nazwa', self::clamp( $buyer->registrationName, self::MAX_TEXT512 ) );
		}

		$this->buildAdres( $node, $buyer );

		// Both flags are mandatory; "2" is the negative answer. Brightfern has no
		// notion of a local-government sub-unit or a VAT-group member.
		$this->el( $node, 'JST', '2' );
		$this->el( $node, 'GV', '2' );
	}

	/**
	 * FA(3)'s own address type: a country code plus free-text lines. Deliberately
	 * NOT the Ministry base library's same-named type — see the class docblock.
	 */
	private function buildAdres( DOMElement $parent, Party $party ): void {
		$address = $party->address;
		if ( ! $address ) {
			return;
		}

		$line1 = trim( $address->line1 );
		$line2 = trim( implode( ' ', array_filter( array( $address->postalZone, $address->city ) ) ) );

		// AdresL1 is mandatory once the node exists, so fall back to whatever
		// locality information we do have rather than emitting an empty element.
		if ( '' === $line1 ) {
			$line1 = '' !== $line2 ? $line2 : '-';
			$line2 = '';
		}

		$node = $this->el( $parent, 'Adres' );
		$this->el( $node, 'KodKraju', $address->countryCode );
		$this->el( $node, 'AdresL1', self::clamp( $line1, self::MAX_TEXT512 ) );
		if ( '' !== $line2 ) {
			$this->el( $node, 'AdresL2', self::clamp( $line2, self::MAX_TEXT512 ) );
		}
	}

	/* ------------------------------------------------------------------ *
	 * Fa — the invoice body
	 * ------------------------------------------------------------------ */

	private function buildFa( DOMElement $root, Invoice $invoice ): void {
		$fa = $this->el( $root, 'Fa' );
		// A korekta in minus: the refund is the correction, so every value it
		// carries is negative relative to the invoice being corrected.
		$sign = $invoice->isCreditNote() ? -1.0 : 1.0;

		$this->el( $fa, 'KodWaluty', $invoice->currency );
		$this->el( $fa, 'P_1', $invoice->issueDate );
		$this->el( $fa, 'P_2', self::clamp( $invoice->number, self::MAX_TEXT ) );

		if ( '' !== $invoice->taxPointDate ) {
			$this->el( $fa, 'P_6', $invoice->taxPointDate );
		}

		$this->buildRateBuckets( $fa, $invoice, $sign );

		$this->el( $fa, 'P_15', Num::amount( $sign * $invoice->totals->taxInclusiveAmount ) );
		$this->buildAdnotacje( $fa, $invoice );
		$this->el( $fa, 'RodzajFaktury', $invoice->isCreditNote() ? self::KIND_CORRECTION : self::KIND_INVOICE );

		// The correction block belongs HERE — between RodzajFaktury and FaWiersz.
		if ( $invoice->isCreditNote() ) {
			$this->buildKorekta( $fa, $invoice );
		}

		foreach ( $invoice->lines as $index => $line ) {
			$this->buildWiersz( $fa, $line, $index + 1, $sign );
		}

		$this->buildPlatnosc( $fa, $invoice );
	}

	/**
	 * Accumulate the VAT breakdown into the statutory header buckets. Several
	 * EN 16931 rows can land in one bucket (23% and 22% are both "the standard
	 * rate"), so they are summed rather than overwritten.
	 */
	private function buildRateBuckets( DOMElement $fa, Invoice $invoice, float $sign ): void {
		/** @var array<string,float> $totals */
		$totals = array();

		foreach ( $invoice->taxSubtotals as $row ) {
			$bucket = self::rateBucket( $row->categoryCode, $row->percent );
			if ( null === $bucket ) {
				throw new \RuntimeException(
					sprintf(
						'VAT rate %s%% (category %s) has no equivalent in the Polish FA(3) rate scheme.',
						Num::percent( $row->percent ),
						$row->categoryCode
					)
				);
			}

			[ , $netField, $vatField ] = $bucket;
			$totals[ $netField ]       = ( $totals[ $netField ] ?? 0.0 ) + $sign * $row->taxableAmount;
			if ( null !== $vatField ) {
				$totals[ $vatField ] = ( $totals[ $vatField ] ?? 0.0 ) + $sign * $row->taxAmount;
			}
		}

		foreach ( self::BUCKET_ORDER as $field ) {
			if ( array_key_exists( $field, $totals ) ) {
				$this->el( $fa, $field, Num::amount( $totals[ $field ] ) );
			}
		}
	}

	/**
	 * The mandatory annotations block. Every child is required, and each has an
	 * explicit "does not apply" value — so a plain commercial invoice still has
	 * to say so about cash accounting, self-billing, margin schemes and the rest.
	 */
	private function buildAdnotacje( DOMElement $fa, Invoice $invoice ): void {
		$node = $this->el( $fa, 'Adnotacje' );

		$hasCategory = static function ( Invoice $inv, string $category ): bool {
			foreach ( $inv->taxSubtotals as $row ) {
				if ( $category === $row->categoryCode ) {
					return true;
				}
			}
			return false;
		};

		$this->el( $node, 'P_16', '2' ); // Cash accounting ("metoda kasowa").
		$this->el( $node, 'P_17', '2' ); // Self-billing.
		$this->el( $node, 'P_18', $hasCategory( $invoice, VatCategory::REVERSE_CHARGE ) ? '1' : '2' );
		// Split payment is mandatory only for annex-15 goods above PLN 15 000.
		// Brightfern has no annex-15 product classification, so it cannot assert
		// the positive case; documented as a v1 limitation.
		$this->el( $node, 'P_18A', '2' );

		$zwolnienie = $this->el( $node, 'Zwolnienie' );
		if ( $hasCategory( $invoice, VatCategory::EXEMPT ) ) {
			$this->el( $zwolnienie, 'P_19', '1' );
			// P_19A/B/C distinguish a national-law basis, a 2006/112/EC basis, and
			// "other". Brightfern knows THAT a supply is exempt (BT-120) but not
			// under which Polish article, so it states the reason under "other"
			// rather than asserting a statute it cannot verify.
			$this->el( $zwolnienie, 'P_19C', self::clamp( $this->exemptionReason( $invoice ), self::MAX_TEXT ) );
		} else {
			$this->el( $zwolnienie, 'P_19N', '1' );
		}

		$transport = $this->el( $node, 'NoweSrodkiTransportu' );
		$this->el( $transport, 'P_22N', '1' );

		$this->el( $node, 'P_23', '2' ); // Simplified triangulation procedure.

		$marza = $this->el( $node, 'PMarzy' );
		$this->el( $marza, 'P_PMarzyN', '1' );
	}

	private function exemptionReason( Invoice $invoice ): string {
		foreach ( $invoice->taxSubtotals as $row ) {
			if ( VatCategory::EXEMPT === $row->categoryCode && null !== $row->exemptionReasonText && '' !== $row->exemptionReasonText ) {
				return $row->exemptionReasonText;
			}
		}
		return 'Zwolnienie z podatku VAT';
	}

	/**
	 * Data of the invoice being corrected. The KSeF-number branch is not
	 * available to us: an invoice only has a KSeF number once KSeF has accepted
	 * it, and v1 does not transmit — so we take the schema's explicit
	 * "corrected invoice was issued outside KSeF" branch, which is also the
	 * truthful one for a document the merchant uploads by hand.
	 */
	/**
	 * Emits PrzyczynaKorekty (correction reason) then DaneFaKorygowanej — in that
	 * order, as siblings; PrzyczynaKorekty is NOT a child of DaneFaKorygowanej,
	 * which is easy to assume wrongly since they read as one unit.
	 *
	 * PrzyczynaKorekty is filled from Invoice::$note, which WooOrderAdapter
	 * already populates from the refund's own reason field (BT-22 on the credit
	 * note) — UblBisSerializer and CiiSerializer both emit it; this was the one
	 * serializer silently dropping it.
	 *
	 * TypKorekty (which VAT period the correction books against) is deliberately
	 * left unset. It is optional in the schema, but asserting a value is a real
	 * tax-timing judgement, not a formatting choice — the same reasoning that
	 * keeps P_18A (split payment) and the exemption article number unset
	 * elsewhere in this class: only assert what the order data actually proves.
	 */
	private function buildKorekta( DOMElement $fa, Invoice $invoice ): void {
		// A korekta that cannot name the invoice it corrects is not a correction
		// at all, and NrFaKorygowanej is mandatory — emitting it empty produces a
		// document KSeF rejects. Refuse with something the merchant can act on.
		if ( '' === trim( $invoice->precedingInvoiceNumber ) ) {
			throw new \RuntimeException(
				'A KSeF correction invoice must reference the number of the invoice it corrects.'
			);
		}

		if ( '' !== trim( $invoice->note ) ) {
			$this->el( $fa, 'PrzyczynaKorekty', self::clamp( $invoice->note, self::MAX_TEXT ) );
		}

		$node = $this->el( $fa, 'DaneFaKorygowanej' );

		$correctedDate = '' !== $invoice->precedingInvoiceIssueDate
			? $invoice->precedingInvoiceIssueDate
			: $invoice->issueDate;

		$this->el( $node, 'DataWystFaKorygowanej', $correctedDate );
		$this->el( $node, 'NrFaKorygowanej', self::clamp( $invoice->precedingInvoiceNumber, self::MAX_TEXT ) );
		$this->el( $node, 'NrKSeFN', '1' );
	}

	private function buildWiersz( DOMElement $fa, InvoiceLine $line, int $number, float $sign ): void {
		$node = $this->el( $fa, 'FaWiersz' );

		$this->el( $node, 'NrWierszaFa', (string) $number );
		$this->el( $node, 'P_7', self::clamp( $line->name, self::MAX_TEXT512 ) );
		if ( '' !== $line->sellerItemId ) {
			$this->el( $node, 'Indeks', self::clamp( $line->sellerItemId, self::MAX_TEXT50 ) );
		}
		if ( '' !== $line->standardItemId ) {
			$this->el( $node, 'GTIN', self::clamp( $line->standardItemId, self::MAX_GTIN ) );
		}
		$this->el( $node, 'P_8A', self::clamp( self::unitOfMeasure( $line->unitCode ), self::MAX_TEXT ) );
		// On a korekta the quantity carries the sign, so unit price stays positive
		// and P_9A x P_8B still reconciles with P_11.
		$this->el( $node, 'P_8B', Num::quantity( $sign * $line->quantity ) );
		$this->el( $node, 'P_9A', Num::amount( $line->netPriceAmount ) );

		// P_10: the discount that makes P_9A x P_8B reconcile with P_11. Without
		// it, any discounted line (WooCommerce coupons pro-rated onto lines are
		// the common trigger) leaves unit-price x quantity silently disagreeing
		// with the line's net value — schema-valid (P_10 is optional) but a
		// document a reader can't reconcile. Signed like P_11, NOT like P_9A:
		// on a korekta P_8B is already negative while P_9A stays positive, so
		// P_10 must also flip sign for P_9A*P_8B-P_10=P_11 to hold. E.g. a full
		// credit of qty=2/price=100/discount=40/net=160 becomes P_8B=-2/P_9A=100
		// /P_11=-160; solving 100*(-2)-P_10=-160 gives P_10=-40, i.e. sign*40.
		$discount = 0.0;
		foreach ( $line->allowanceCharges as $ac ) {
			$discount += $ac->isCharge ? -$ac->amount : $ac->amount;
		}
		if ( ! Num::moneyEquals( $discount, 0.0 ) ) {
			$this->el( $node, 'P_10', Num::amount( $sign * $discount ) );
		}

		$this->el( $node, 'P_11', Num::amount( $sign * $line->lineExtensionAmount ) );

		$bucket = self::rateBucket( $line->vatCategoryCode, $line->vatPercent );
		if ( null === $bucket ) {
			throw new \RuntimeException(
				sprintf(
					'Line "%s" uses VAT rate %s%% (category %s), which has no equivalent in the Polish FA(3) rate scheme.',
					$line->name,
					Num::percent( $line->vatPercent ),
					$line->vatCategoryCode
				)
			);
		}
		$this->el( $node, 'P_12', $bucket[0] );
	}

	private function buildPlatnosc( DOMElement $fa, Invoice $invoice ): void {
		$p = $invoice->payment;

		$hasDueDate = ! $invoice->isCreditNote() && '' !== $invoice->dueDate;
		if ( ! $hasDueDate && '' === $p->payeeIban ) {
			return;
		}

		$node = $this->el( $fa, 'Platnosc' );

		if ( $hasDueDate ) {
			$term = $this->el( $node, 'TerminPlatnosci' );
			$this->el( $term, 'Termin', $invoice->dueDate );
		}

		// The FA(3) payment forms are a short domestic list; every credit-transfer
		// variant Brightfern offers (BT-81 "30" and SEPA "58") is a przelew.
		$this->el( $node, 'FormaPlatnosci', '6' );

		if ( '' !== $p->payeeIban ) {
			$account = $this->el( $node, 'RachunekBankowy' );
			$this->el( $account, 'NrRB', preg_replace( '/\s+/', '', $p->payeeIban ) ?? '' );
			if ( '' !== $p->payeeBic ) {
				$this->el( $account, 'SWIFT', $p->payeeBic );
			}
		}
	}

	/* ------------------------------------------------------------------ *
	 * Helpers
	 * ------------------------------------------------------------------ */

	/**
	 * The seller's Polish tax identifier. FA(3) has no branch for a non-Polish
	 * seller: Podmiot1 requires a NIP outright, which is the schema stating that
	 * KSeF is for taxpayers registered in Poland.
	 */
	private function requireNip( Party $seller ): string {
		$nip = self::polishNip( $seller->vatNumber );

		if ( null === $nip ) {
			throw new \RuntimeException(
				'KSeF requires a Polish seller: set a valid Polish VAT number (NIP) in Brightfern settings.'
			);
		}

		return $nip;
	}

	/**
	 * The Polish NIP inside a VAT number, or null if there isn't one. Accepts
	 * both "PL5260001246" and a bare "5260001246"; the pattern is the schema's
	 * own, so anything this returns is guaranteed to satisfy TNrNIP.
	 *
	 * Public for the same reason as {@see rateBucket()} — the validator needs it.
	 */
	public static function polishNip( string $vatNumber ): ?string {
		$value  = self::digitsAndLetters( $vatNumber );
		$digits = str_starts_with( $value, 'PL' ) ? substr( $value, 2 ) : $value;

		return self::isValidNip( $digits ) ? $digits : null;
	}

	/**
	 * P_8A is prose ("miara dostarczonych towarow"), not a coded field, so the
	 * UN/ECE Rec 20 code the rest of the model carries would print as machine
	 * noise on a Polish invoice. Unknown codes pass through unchanged — better a
	 * recognisable code than a wrong translation.
	 */
	private static function unitOfMeasure( string $unitCode ): string {
		return match ( strtoupper( $unitCode ) ) {
			'C62', 'H87', 'EA' => 'szt.',
			'KGM'              => 'kg',
			'GRM'              => 'g',
			'TNE'              => 't',
			'MTR'              => 'm',
			'CMT'              => 'cm',
			'MTK'              => 'm2',
			'MTQ'              => 'm3',
			'LTR'              => 'l',
			'MLT'              => 'ml',
			'HUR'              => 'godz.',
			'DAY'              => 'dzien',
			'MON'              => 'mies.',
			'ANN'              => 'rok',
			'KMT'              => 'km',
			'SET'              => 'kpl.',
			'PR'               => 'para',
			default            => $unitCode,
		};
	}

	private static function isValidNip( string $digits ): bool {
		return 1 === preg_match( '/^[1-9]((\d[1-9])|([1-9]\d))\d{7}$/', $digits );
	}

	private static function digitsAndLetters( string $value ): string {
		return strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', $value ) ?? '' );
	}

	/**
	 * Truncate to a schema length cap.
	 *
	 * Deliberately mb_substr, not substr: the XSD's maxLength counts CHARACTERS
	 * while substr counts bytes, so cutting Polish text (or any accented name)
	 * at a byte offset can split a multi-byte sequence and emit XML that is not
	 * merely too long but malformed.
	 */
	private static function clamp( string $value, int $max ): string {
		return mb_substr( $value, 0, $max );
	}

	private function el( DOMElement $parent, string $name, ?string $value = null ): DOMElement {
		// Text node rather than createElementNS()'s value argument, for the same
		// reason as the other serializers: a bare "&" would warn and truncate
		// instead of being escaped on serialization.
		$el = $this->doc->createElementNS( self::NS, $name );
		if ( null !== $value && '' !== $value ) {
			$el->appendChild( $this->doc->createTextNode( $value ) );
		}
		$parent->appendChild( $el );
		return $el;
	}
}
