<?php

declare( strict_types=1 );

namespace Brightfern\Invoice;

use DOMDocument;
use DOMElement;
use Brightfern\Invoice\Model\AllowanceCharge;
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 Peppol BIS Billing 3.0 UBL XML
 * — the syntax that actually travels the Peppol network for every country we
 * support (BE, and FR/DE when sent via Peppol rather than a national portal).
 *
 * Element ORDER matters: UBL is validated against an ordered XSD, so the build
 * methods emit children in the sequence the schema demands. The cbc:* and cac:*
 * prefixes map to the two UBL common-component namespaces declared on the root.
 *
 * National divergence (documented, not silently ignored):
 *  - France Factur-X is a *hybrid PDF/A-3 carrying CII XML* for the domestic
 *    PPF flow — a different syntax (UN/CEFACT CII, not UBL). When France is
 *    reached over Peppol, this UBL BIS document is still the correct transit
 *    format. {@see FacturXBuilder} owns the PDF path, shipped since 0.1.7.
 *  - Germany XRechnung uses the same UBL syntax and the same ProfileID, just a
 *    different CustomizationID — {@see self::forXRechnung()}. The always-on
 *    business rules plus that identifier cover most real-world validation;
 *    an optional XRechnung schematron slot (see resources/README.md) is the
 *    path to full German CIUS rule coverage, the same pattern already used
 *    for the Peppol schematron.
 */
final class UblBisSerializer {

	private const NS_INVOICE     = 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2';
	private const NS_CREDIT_NOTE = 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2';
	private const NS_CAC         = 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2';
	private const NS_CBC         = 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2';

	/** BT-24: identifies the document as EN 16931-compliant Peppol BIS Billing 3.0. */
	public const CUSTOMIZATION_ID_PEPPOL = 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0';
	/** BT-24: identifies the document as EN 16931-compliant XRechnung 3.0 (UBL binding). */
	public const CUSTOMIZATION_ID_XRECHNUNG = 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0';
	/** BT-23: the Peppol billing business process — identical for both formats. */
	public const PROFILE_ID_PEPPOL = 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0';

	private DOMDocument $doc;

	/** Root element name and line/quantity element names differ between Invoice and CreditNote. */
	private bool   $isCreditNote      = false;
	private string $lineElement       = 'InvoiceLine';
	private string $quantityElement   = 'InvoicedQuantity';

	public function __construct(
		private string $customizationId = self::CUSTOMIZATION_ID_PEPPOL,
		private string $profileId = self::PROFILE_ID_PEPPOL,
	) {}

	/** XRechnung 3.0 (Germany), UBL syntax binding — same ProfileID as Peppol. */
	public static function forXRechnung(): self {
		return new self( self::CUSTOMIZATION_ID_XRECHNUNG );
	}

	public function serialize( Invoice $invoice ): string {
		$this->doc                     = new DOMDocument( '1.0', 'UTF-8' );
		$this->doc->formatOutput       = true;
		$this->doc->preserveWhiteSpace = false;

		$this->isCreditNote    = $invoice->isCreditNote();
		$this->lineElement     = $this->isCreditNote ? 'CreditNoteLine' : 'InvoiceLine';
		$this->quantityElement = $this->isCreditNote ? 'CreditedQuantity' : 'InvoicedQuantity';

		$rootName = $this->isCreditNote ? 'CreditNote' : 'Invoice';
		$rootNs   = $this->isCreditNote ? self::NS_CREDIT_NOTE : self::NS_INVOICE;

		$root = $this->doc->createElementNS( $rootNs, $rootName );
		$root->setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:cac', self::NS_CAC );
		$root->setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:cbc', self::NS_CBC );
		$this->doc->appendChild( $root );

		$cur = $invoice->currency;

		// --- Document header (BT-24, BT-23, BT-1, BT-2, BT-9, BT-3, BT-22, BT-5, BT-10) ---
		$this->cbc( $root, 'CustomizationID', $this->customizationId );
		$this->cbc( $root, 'ProfileID', $this->profileId );
		$this->cbc( $root, 'ID', $invoice->number );
		$this->cbc( $root, 'IssueDate', $invoice->issueDate );
		if ( ! $this->isCreditNote && '' !== $invoice->dueDate ) {
			$this->cbc( $root, 'DueDate', $invoice->dueDate );
		}
		$this->cbc( $root, $this->isCreditNote ? 'CreditNoteTypeCode' : 'InvoiceTypeCode', $invoice->typeCode );
		if ( '' !== $invoice->note ) {
			$this->cbc( $root, 'Note', $invoice->note );
		}
		$this->cbc( $root, 'DocumentCurrencyCode', $cur );
		if ( '' !== $invoice->buyerReference ) {
			$this->cbc( $root, 'BuyerReference', $invoice->buyerReference );
		}

		// BT-13 Order reference.
		if ( '' !== $invoice->orderReference ) {
			$orderRef = $this->cac( $root, 'OrderReference' );
			$this->cbc( $orderRef, 'ID', $invoice->orderReference );
		}

		// BT-25 Preceding invoice reference (credit notes only).
		if ( $this->isCreditNote && '' !== $invoice->precedingInvoiceNumber ) {
			$billingRef = $this->cac( $root, 'BillingReference' );
			$invoiceRef = $this->cac( $billingRef, 'InvoiceDocumentReference' );
			$this->cbc( $invoiceRef, 'ID', $invoice->precedingInvoiceNumber );
			if ( '' !== $invoice->precedingInvoiceIssueDate ) {
				$this->cbc( $invoiceRef, 'IssueDate', $invoice->precedingInvoiceIssueDate );
			}
		}

		// --- Parties (BG-4 seller, BG-7 buyer) ---
		$supplier = $this->cac( $root, 'AccountingSupplierParty' );
		$this->buildParty( $this->cac( $supplier, 'Party' ), $invoice->seller, true );

		$customer = $this->cac( $root, 'AccountingCustomerParty' );
		$this->buildParty( $this->cac( $customer, 'Party' ), $invoice->buyer, false );

		// --- Payment (BG-16) ---
		$this->buildPaymentMeans( $root, $invoice );
		if ( '' !== $invoice->payment->terms ) {
			$terms = $this->cac( $root, 'PaymentTerms' );
			$this->cbc( $terms, 'Note', $invoice->payment->terms );
		}

		// --- Document-level allowances/charges (BG-20/BG-21) — before TaxTotal ---
		foreach ( $invoice->allowanceCharges as $ac ) {
			$this->buildDocumentAllowanceCharge( $root, $ac, $cur );
		}

		// --- VAT total + breakdown (BG-23) ---
		$this->buildTaxTotal( $root, $invoice );

		// --- Document totals (BG-22) ---
		$this->buildMonetaryTotal( $root, $invoice );

		// --- Lines (BG-25) ---
		foreach ( $invoice->lines as $line ) {
			$this->buildInvoiceLine( $root, $line, $cur );
		}

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

	/* ------------------------------------------------------------------ *
	 * Party
	 * ------------------------------------------------------------------ */

	private function buildParty( DOMElement $partyEl, Party $party, bool $isSeller ): void {
		// BT-34 / BT-49 Peppol electronic address — the network routing key.
		if ( '' !== $party->endpointId ) {
			$endpoint = $this->cbc( $partyEl, 'EndpointID', $party->endpointId );
			$endpoint->setAttribute( 'schemeID', $party->endpointScheme );
		}

		// PartyName (BT-28/BT-45 trading name) — fall back to legal name.
		$name      = $this->cac( $partyEl, 'PartyName' );
		$this->cbc( $name, 'Name', '' !== $party->tradingName ? $party->tradingName : $party->registrationName );

		// Postal address (BG-5 / BG-8). Country code is the only mandatory part.
		$addr = $this->cac( $partyEl, 'PostalAddress' );
		if ( $party->address ) {
			if ( '' !== $party->address->line1 ) {
				$this->cbc( $addr, 'StreetName', $party->address->line1 );
			}
			if ( '' !== $party->address->line2 ) {
				$this->cbc( $addr, 'AdditionalStreetName', $party->address->line2 );
			}
			if ( '' !== $party->address->city ) {
				$this->cbc( $addr, 'CityName', $party->address->city );
			}
			if ( '' !== $party->address->postalZone ) {
				$this->cbc( $addr, 'PostalZone', $party->address->postalZone );
			}
			if ( '' !== $party->address->countrySubentity ) {
				$this->cbc( $addr, 'CountrySubentity', $party->address->countrySubentity );
			}
			$country = $this->cac( $addr, 'Country' );
			$this->cbc( $country, 'IdentificationCode', $party->address->countryCode );
		}

		// PartyTaxScheme (BT-31/BT-48 VAT).
		if ( $party->hasVat() ) {
			$taxScheme = $this->cac( $partyEl, 'PartyTaxScheme' );
			$this->cbc( $taxScheme, 'CompanyID', $party->vatNumber );
			$scheme = $this->cac( $taxScheme, 'TaxScheme' );
			$this->cbc( $scheme, 'ID', 'VAT' );
		}

		// PartyLegalEntity (BT-27/BT-44 registration name + BT-30/BT-47 company id).
		$legal = $this->cac( $partyEl, 'PartyLegalEntity' );
		$this->cbc( $legal, 'RegistrationName', $party->registrationName );
		if ( '' !== $party->companyId ) {
			$companyId = $this->cbc( $legal, 'CompanyID', $party->companyId );
			if ( '' !== $party->companyIdScheme ) {
				$companyId->setAttribute( 'schemeID', $party->companyIdScheme );
			}
		}

		// Contact (BG-6 / BG-9).
		if ( '' !== $party->contactName || '' !== $party->contactEmail || '' !== $party->contactPhone ) {
			$contact = $this->cac( $partyEl, 'Contact' );
			if ( '' !== $party->contactName ) {
				$this->cbc( $contact, 'Name', $party->contactName );
			}
			if ( '' !== $party->contactPhone ) {
				$this->cbc( $contact, 'Telephone', $party->contactPhone );
			}
			if ( '' !== $party->contactEmail ) {
				$this->cbc( $contact, 'ElectronicMail', $party->contactEmail );
			}
		}
	}

	/* ------------------------------------------------------------------ *
	 * Payment
	 * ------------------------------------------------------------------ */

	private function buildPaymentMeans( DOMElement $root, Invoice $invoice ): void {
		$p = $invoice->payment;
		if ( '' === $p->meansCode && '' === $p->payeeIban ) {
			return;
		}

		$means = $this->cac( $root, 'PaymentMeans' );
		$code  = $this->cbc( $means, 'PaymentMeansCode', '' !== $p->meansCode ? $p->meansCode : '30' );
		if ( '' !== $p->meansText ) {
			$code->setAttribute( 'name', $p->meansText );
		}
		if ( '' !== $p->remittanceInfo ) {
			$this->cbc( $means, 'PaymentID', $p->remittanceInfo );
		}
		if ( '' !== $p->payeeIban ) {
			$account = $this->cac( $means, 'PayeeFinancialAccount' );
			$this->cbc( $account, 'ID', $p->payeeIban );
			if ( '' !== $p->payeeName ) {
				$this->cbc( $account, 'Name', $p->payeeName );
			}
			if ( '' !== $p->payeeBic ) {
				$branch = $this->cac( $account, 'FinancialInstitutionBranch' );
				$this->cbc( $branch, 'ID', $p->payeeBic );
			}
		}
	}

	/* ------------------------------------------------------------------ *
	 * Allowance / Charge (document level)
	 * ------------------------------------------------------------------ */

	private function buildDocumentAllowanceCharge( DOMElement $root, AllowanceCharge $ac, string $cur ): void {
		$el = $this->cac( $root, 'AllowanceCharge' );
		$this->cbc( $el, 'ChargeIndicator', $ac->isCharge ? 'true' : 'false' );
		if ( '' !== $ac->reasonCode ) {
			$this->cbc( $el, 'AllowanceChargeReasonCode', $ac->reasonCode );
		}
		if ( '' !== $ac->reasonText ) {
			$this->cbc( $el, 'AllowanceChargeReason', $ac->reasonText );
		}
		$amount = $this->cbc( $el, 'Amount', Num::amount( $ac->amount ) );
		$amount->setAttribute( 'currencyID', $cur );

		// Document-level allowance/charge MUST state its VAT category (feeds BG-23).
		$this->buildTaxCategory( $el, (string) $ac->vatCategoryCode, (float) $ac->vatPercent, null, null );
	}

	/* ------------------------------------------------------------------ *
	 * Tax total + breakdown
	 * ------------------------------------------------------------------ */

	private function buildTaxTotal( DOMElement $root, Invoice $invoice ): void {
		$taxTotal = $this->cac( $root, 'TaxTotal' );
		$amount   = $this->cbc( $taxTotal, 'TaxAmount', Num::amount( $invoice->totals->taxTotalAmount ) );
		$amount->setAttribute( 'currencyID', $invoice->currency );

		foreach ( $invoice->taxSubtotals as $row ) {
			$this->buildTaxSubtotal( $taxTotal, $row, $invoice->currency );
		}
	}

	private function buildTaxSubtotal( DOMElement $taxTotal, TaxSubtotal $row, string $cur ): void {
		$sub      = $this->cac( $taxTotal, 'TaxSubtotal' );
		$taxable  = $this->cbc( $sub, 'TaxableAmount', Num::amount( $row->taxableAmount ) );
		$taxable->setAttribute( 'currencyID', $cur );
		$tax = $this->cbc( $sub, 'TaxAmount', Num::amount( $row->taxAmount ) );
		$tax->setAttribute( 'currencyID', $cur );

		$this->buildTaxCategory( $sub, $row->categoryCode, $row->percent, $row->exemptionReasonCode, $row->exemptionReasonText );
	}

	/**
	 * cac:TaxCategory / cac:ClassifiedTaxCategory body (shared by lines, charges
	 * and breakdown). Out-of-scope (O) carries no percent per BR-O-*.
	 */
	private function buildTaxCategory( DOMElement $parent, string $category, float $percent, ?string $exReasonCode, ?string $exReasonText ): void {
		// Lines use ClassifiedTaxCategory; everything else uses TaxCategory.
		$isLine  = 'Item' === $parent->localName;
		$catEl   = $this->cac( $parent, $isLine ? 'ClassifiedTaxCategory' : 'TaxCategory' );
		$this->cbc( $catEl, 'ID', $category );

		if ( VatCategory::OUT_OF_SCOPE !== $category ) {
			$this->cbc( $catEl, 'Percent', Num::percent( $percent ) );
		}

		// Exemption reason only valid on the breakdown's TaxCategory, not lines.
		if ( ! $isLine ) {
			if ( null !== $exReasonCode && '' !== $exReasonCode ) {
				$this->cbc( $catEl, 'TaxExemptionReasonCode', $exReasonCode );
			}
			if ( null !== $exReasonText && '' !== $exReasonText ) {
				$this->cbc( $catEl, 'TaxExemptionReason', $exReasonText );
			}
		}

		$scheme = $this->cac( $catEl, 'TaxScheme' );
		$this->cbc( $scheme, 'ID', 'VAT' );
	}

	/* ------------------------------------------------------------------ *
	 * Monetary totals
	 * ------------------------------------------------------------------ */

	private function buildMonetaryTotal( DOMElement $root, Invoice $invoice ): void {
		$t    = $invoice->totals;
		$cur  = $invoice->currency;
		$lmt  = $this->cac( $root, 'LegalMonetaryTotal' );

		$this->amountEl( $lmt, 'LineExtensionAmount', $t->lineExtensionAmount, $cur );
		$this->amountEl( $lmt, 'TaxExclusiveAmount', $t->taxExclusiveAmount, $cur );
		$this->amountEl( $lmt, 'TaxInclusiveAmount', $t->taxInclusiveAmount, $cur );
		if ( $t->allowanceTotalAmount > 0.0 ) {
			$this->amountEl( $lmt, 'AllowanceTotalAmount', $t->allowanceTotalAmount, $cur );
		}
		if ( $t->chargeTotalAmount > 0.0 ) {
			$this->amountEl( $lmt, 'ChargeTotalAmount', $t->chargeTotalAmount, $cur );
		}
		if ( $t->prepaidAmount > 0.0 ) {
			$this->amountEl( $lmt, 'PrepaidAmount', $t->prepaidAmount, $cur );
		}
		if ( ! Num::moneyEquals( $t->payableRoundingAmount, 0.0 ) ) {
			$this->amountEl( $lmt, 'PayableRoundingAmount', $t->payableRoundingAmount, $cur );
		}
		$this->amountEl( $lmt, 'PayableAmount', $t->payableAmount, $cur );
	}

	/* ------------------------------------------------------------------ *
	 * Invoice line
	 * ------------------------------------------------------------------ */

	private function buildInvoiceLine( DOMElement $root, InvoiceLine $line, string $cur ): void {
		$el = $this->cac( $root, $this->lineElement );
		$this->cbc( $el, 'ID', $line->id );
		if ( '' !== $line->note ) {
			$this->cbc( $el, 'Note', $line->note );
		}
		$qty = $this->cbc( $el, $this->quantityElement, Num::quantity( $line->quantity ) );
		$qty->setAttribute( 'unitCode', $line->unitCode );
		$this->amountEl( $el, 'LineExtensionAmount', $line->lineExtensionAmount, $cur );

		// Line-level allowance/charge (e.g. discount).
		foreach ( $line->allowanceCharges as $ac ) {
			$acEl = $this->cac( $el, 'AllowanceCharge' );
			$this->cbc( $acEl, 'ChargeIndicator', $ac->isCharge ? 'true' : 'false' );
			if ( '' !== $ac->reasonCode ) {
				$this->cbc( $acEl, 'AllowanceChargeReasonCode', $ac->reasonCode );
			}
			if ( '' !== $ac->reasonText ) {
				$this->cbc( $acEl, 'AllowanceChargeReason', $ac->reasonText );
			}
			$this->amountEl( $acEl, 'Amount', $ac->amount, $cur );
		}

		// Item (BG-31).
		$item = $this->cac( $el, 'Item' );
		if ( '' !== $line->description ) {
			$this->cbc( $item, 'Description', $line->description );
		}
		$this->cbc( $item, 'Name', $line->name );
		if ( '' !== $line->sellerItemId ) {
			$sellerId = $this->cac( $item, 'SellersItemIdentification' );
			$this->cbc( $sellerId, 'ID', $line->sellerItemId );
		}
		if ( '' !== $line->standardItemId ) {
			$stdId = $this->cac( $item, 'StandardItemIdentification' );
			$idEl  = $this->cbc( $stdId, 'ID', $line->standardItemId );
			if ( '' !== $line->standardItemScheme ) {
				$idEl->setAttribute( 'schemeID', $line->standardItemScheme );
			}
		}
		$this->buildTaxCategory( $item, $line->vatCategoryCode, $line->vatPercent, null, null );

		// Price (BG-29).
		$price = $this->cac( $el, 'Price' );
		$this->amountEl( $price, 'PriceAmount', $line->netPriceAmount, $cur );
		if ( $line->baseQuantity > 0.0 && ! Num::moneyEquals( $line->baseQuantity, 1.0 ) ) {
			$base = $this->cbc( $price, 'BaseQuantity', Num::quantity( $line->baseQuantity ) );
			$base->setAttribute( 'unitCode', $line->unitCode );
		}
	}

	/* ------------------------------------------------------------------ *
	 * DOM helpers
	 * ------------------------------------------------------------------ */

	private function cbc( DOMElement $parent, string $name, ?string $value = null ): DOMElement {
		// Create the element empty and attach the value as a TEXT NODE. This is
		// the only safe way: createElementNS()'s value argument parses entities,
		// so a bare "&" in a company name would warn and truncate. createTextNode
		// escapes &, < and > correctly on serialization — no double-encoding.
		$el = $this->doc->createElementNS( self::NS_CBC, 'cbc:' . $name );
		if ( null !== $value && '' !== $value ) {
			$el->appendChild( $this->doc->createTextNode( $value ) );
		}
		$parent->appendChild( $el );
		return $el;
	}

	private function cac( DOMElement $parent, string $name ): DOMElement {
		$el = $this->doc->createElementNS( self::NS_CAC, 'cac:' . $name );
		$parent->appendChild( $el );
		return $el;
	}

	private function amountEl( DOMElement $parent, string $name, float $value, string $cur ): DOMElement {
		$el = $this->cbc( $parent, $name, Num::amount( $value ) );
		$el->setAttribute( 'currencyID', $cur );
		return $el;
	}
}
