<?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 UN/CEFACT Cross Industry
 * Invoice (CII) XML — the syntax Factur-X embeds in its PDF/A-3 hybrid
 * (France's domestic PPF/PDP flow). This is a genuinely different XML
 * dialect from {@see UblBisSerializer}'s UBL — different namespaces, element
 * names and nesting — but consumes the exact same syntax-neutral model, no
 * changes needed there.
 *
 * Structure verified against the official bundled EN 16931 profile XSD
 * (vendor/atgp/factur-x/xsd/factur-x/en16931/) rather than assumed: unlike
 * UBL, CII has a SINGLE root type (CrossIndustryInvoiceType) reused for both
 * invoices and credit notes — the UNTDID 1001 TypeCode value (380/381) is the
 * only distinguishing signal, there is no separate CreditNote root. That XSD
 * is also run automatically by the Factur-X writer library at PDF-embedding
 * time (atgp/factur-x, XSD validation on by default), which is the
 * authoritative correctness check for this class's output.
 *
 * Element ORDER matters here exactly as it does for UBL — CII is validated
 * against an ordered XSD too.
 */
final class CiiSerializer {

	private const NS_RSM = 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100';
	private const NS_RAM = 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100';
	private const NS_UDT = 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100';
	private const NS_QDT = 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100';

	/** The EN 16931 guideline this document conforms to (Factur-X EN16931 profile). */
	public const GUIDELINE_EN16931 = 'urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:en16931';

	private DOMDocument $doc;

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

		$root = $this->doc->createElementNS( self::NS_RSM, 'rsm:CrossIndustryInvoice' );
		$root->setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:ram', self::NS_RAM );
		$root->setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:udt', self::NS_UDT );
		$root->setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:qdt', self::NS_QDT );
		$this->doc->appendChild( $root );

		$this->buildDocumentContext( $root );
		$this->buildExchangedDocument( $root, $invoice );

		$transaction = $this->rsm( $root, 'SupplyChainTradeTransaction' );
		foreach ( $invoice->lines as $line ) {
			$this->buildLineItem( $transaction, $line );
		}
		$this->buildTradeAgreement( $transaction, $invoice );
		// ApplicableHeaderTradeDelivery is required to be present even with no
		// content — Brightfern doesn't capture shipping data for this document.
		$this->ram( $transaction, 'ApplicableHeaderTradeDelivery' );
		$this->buildTradeSettlement( $transaction, $invoice );

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

	/* ------------------------------------------------------------------ *
	 * Header
	 * ------------------------------------------------------------------ */

	private function buildDocumentContext( DOMElement $root ): void {
		$context   = $this->rsm( $root, 'ExchangedDocumentContext' );
		$guideline = $this->ram( $context, 'GuidelineSpecifiedDocumentContextParameter' );
		$this->ram( $guideline, 'ID', self::GUIDELINE_EN16931 );
	}

	private function buildExchangedDocument( DOMElement $root, Invoice $invoice ): void {
		$doc = $this->rsm( $root, 'ExchangedDocument' );
		$this->ram( $doc, 'ID', $invoice->number );
		$this->ram( $doc, 'TypeCode', $invoice->typeCode );
		$issue = $this->ram( $doc, 'IssueDateTime' );
		$this->udtDate( $issue, $invoice->issueDate );
		if ( '' !== $invoice->note ) {
			$note = $this->ram( $doc, 'IncludedNote' );
			$this->ram( $note, 'Content', $invoice->note );
		}
	}

	/* ------------------------------------------------------------------ *
	 * Trade agreement (parties, references)
	 * ------------------------------------------------------------------ */

	private function buildTradeAgreement( DOMElement $transaction, Invoice $invoice ): void {
		$agreement = $this->ram( $transaction, 'ApplicableHeaderTradeAgreement' );
		if ( '' !== $invoice->buyerReference ) {
			$this->ram( $agreement, 'BuyerReference', $invoice->buyerReference );
		}
		$this->buildTradeParty( $this->ram( $agreement, 'SellerTradeParty' ), $invoice->seller );
		$this->buildTradeParty( $this->ram( $agreement, 'BuyerTradeParty' ), $invoice->buyer );
		if ( '' !== $invoice->orderReference ) {
			$orderRef = $this->ram( $agreement, 'BuyerOrderReferencedDocument' );
			$this->ram( $orderRef, 'IssuerAssignedID', $invoice->orderReference );
		}
	}

	private function buildTradeParty( DOMElement $partyEl, Party $party ): void {
		// NOTE the CII/UBL divergence here — the two syntaxes put the legal and
		// trading names in opposite-looking places, and getting it backwards is
		// invisible to the XSD (both elements are optional strings):
		//   CII: BT-27 legal name   -> ram:Name
		//        BT-28 trading name -> ram:SpecifiedLegalOrganization/ram:TradingBusinessName
		//   UBL: BT-27 legal name   -> cac:PartyLegalEntity/cbc:RegistrationName
		//        BT-28 trading name -> cac:PartyName/cbc:Name
		// So UBL's "PartyName" is the TRADING name while CII's "Name" is the
		// LEGAL one. See UblBisSerializer::buildParty() for the mirror image.
		$this->ram( $partyEl, 'Name', $party->registrationName );

		$legal = $this->ram( $partyEl, 'SpecifiedLegalOrganization' );
		if ( '' !== $party->companyId ) {
			$id = $this->ram( $legal, 'ID', $party->companyId );
			if ( '' !== $party->companyIdScheme ) {
				$id->setAttribute( 'schemeID', $party->companyIdScheme );
			}
		}
		if ( '' !== $party->tradingName ) {
			$this->ram( $legal, 'TradingBusinessName', $party->tradingName );
		}

		if ( '' !== $party->contactName || '' !== $party->contactPhone || '' !== $party->contactEmail ) {
			$contact = $this->ram( $partyEl, 'DefinedTradeContact' );
			if ( '' !== $party->contactName ) {
				$this->ram( $contact, 'PersonName', $party->contactName );
			}
			if ( '' !== $party->contactPhone ) {
				$tel = $this->ram( $contact, 'TelephoneUniversalCommunication' );
				$this->ram( $tel, 'CompleteNumber', $party->contactPhone );
			}
			if ( '' !== $party->contactEmail ) {
				$email = $this->ram( $contact, 'EmailURIUniversalCommunication' );
				$this->ram( $email, 'URIID', $party->contactEmail );
			}
		}

		if ( $party->address ) {
			$addr = $this->ram( $partyEl, 'PostalTradeAddress' );
			if ( '' !== $party->address->postalZone ) {
				$this->ram( $addr, 'PostcodeCode', $party->address->postalZone );
			}
			if ( '' !== $party->address->line1 ) {
				$this->ram( $addr, 'LineOne', $party->address->line1 );
			}
			if ( '' !== $party->address->line2 ) {
				$this->ram( $addr, 'LineTwo', $party->address->line2 );
			}
			if ( '' !== $party->address->city ) {
				$this->ram( $addr, 'CityName', $party->address->city );
			}
			$this->ram( $addr, 'CountryID', $party->address->countryCode );
			if ( '' !== $party->address->countrySubentity ) {
				$this->ram( $addr, 'CountrySubDivisionName', $party->address->countrySubentity );
			}
		}

		if ( $party->hasVat() ) {
			$taxReg = $this->ram( $partyEl, 'SpecifiedTaxRegistration' );
			$id     = $this->ram( $taxReg, 'ID', $party->vatNumber );
			$id->setAttribute( 'schemeID', 'VA' );
		}
	}

	/* ------------------------------------------------------------------ *
	 * Trade settlement (payment, tax, totals)
	 * ------------------------------------------------------------------ */

	private function buildTradeSettlement( DOMElement $transaction, Invoice $invoice ): void {
		$settlement = $this->ram( $transaction, 'ApplicableHeaderTradeSettlement' );
		$p          = $invoice->payment;

		if ( '' !== $p->remittanceInfo ) {
			$this->ram( $settlement, 'PaymentReference', $p->remittanceInfo );
		}
		$this->ram( $settlement, 'InvoiceCurrencyCode', $invoice->currency );

		if ( '' !== $p->meansCode || '' !== $p->payeeIban ) {
			$means = $this->ram( $settlement, 'SpecifiedTradeSettlementPaymentMeans' );
			$this->ram( $means, 'TypeCode', '' !== $p->meansCode ? $p->meansCode : '30' );
			if ( '' !== $p->meansText ) {
				$this->ram( $means, 'Information', $p->meansText );
			}
			if ( '' !== $p->payeeIban ) {
				$account = $this->ram( $means, 'PayeePartyCreditorFinancialAccount' );
				$this->ram( $account, 'IBANID', $p->payeeIban );
				if ( '' !== $p->payeeName ) {
					$this->ram( $account, 'AccountName', $p->payeeName );
				}
			}
			if ( '' !== $p->payeeBic ) {
				$institution = $this->ram( $means, 'PayeeSpecifiedCreditorFinancialInstitution' );
				$this->ram( $institution, 'BICID', $p->payeeBic );
			}
		}

		// VAT breakdown (BG-23) — one ApplicableTradeTax per (category, rate).
		foreach ( $invoice->taxSubtotals as $row ) {
			$this->buildTradeTax( $settlement, $row );
		}

		// Document-level allowances/charges (BG-20/BG-21).
		foreach ( $invoice->allowanceCharges as $ac ) {
			$this->buildAllowanceCharge( $settlement, $ac, true );
		}

		if ( '' !== $p->terms || ( ! $invoice->isCreditNote() && '' !== $invoice->dueDate ) ) {
			$terms = $this->ram( $settlement, 'SpecifiedTradePaymentTerms' );
			if ( '' !== $p->terms ) {
				$this->ram( $terms, 'Description', $p->terms );
			}
			if ( ! $invoice->isCreditNote() && '' !== $invoice->dueDate ) {
				$due = $this->ram( $terms, 'DueDateDateTime' );
				$this->udtDate( $due, $invoice->dueDate );
			}
		}

		$this->buildMonetarySummation( $settlement, $invoice );

		// BT-25 preceding invoice reference (credit notes only).
		if ( $invoice->isCreditNote() && '' !== $invoice->precedingInvoiceNumber ) {
			$ref = $this->ram( $settlement, 'InvoiceReferencedDocument' );
			$this->ram( $ref, 'IssuerAssignedID', $invoice->precedingInvoiceNumber );
			if ( '' !== $invoice->precedingInvoiceIssueDate ) {
				$issue = $this->ram( $ref, 'FormattedIssueDateTime' );
				$this->qdtDate( $issue, $invoice->precedingInvoiceIssueDate );
			}
		}
	}

	private function buildTradeTax( DOMElement $settlement, TaxSubtotal $row ): void {
		$tax = $this->ram( $settlement, 'ApplicableTradeTax' );
		// Order per XSD: CalculatedAmount, TypeCode, ExemptionReason, BasisAmount,
		// CategoryCode, ExemptionReasonCode, RateApplicablePercent.
		$this->amount( $tax, 'CalculatedAmount', $row->taxAmount );
		$this->ram( $tax, 'TypeCode', 'VAT' );
		if ( null !== $row->exemptionReasonText && '' !== $row->exemptionReasonText ) {
			$this->ram( $tax, 'ExemptionReason', $row->exemptionReasonText );
		}
		$this->amount( $tax, 'BasisAmount', $row->taxableAmount );
		$this->ram( $tax, 'CategoryCode', $row->categoryCode );
		if ( null !== $row->exemptionReasonCode && '' !== $row->exemptionReasonCode ) {
			$this->ram( $tax, 'ExemptionReasonCode', $row->exemptionReasonCode );
		}
		if ( VatCategory::OUT_OF_SCOPE !== $row->categoryCode ) {
			$this->ram( $tax, 'RateApplicablePercent', Num::percent( $row->percent ) );
		}
	}

	/** Shared by document-level (§settlement) and line-level (§line settlement) allowance/charge. */
	private function buildAllowanceCharge( DOMElement $parent, AllowanceCharge $ac, bool $documentLevel ): void {
		$el        = $this->ram( $parent, 'SpecifiedTradeAllowanceCharge' );
		$indicator = $this->ram( $el, 'ChargeIndicator' );
		$this->udtIndicator( $indicator, $ac->isCharge );
		if ( null !== $ac->percent ) {
			$this->ram( $el, 'CalculationPercent', Num::percent( $ac->percent ) );
		}
		if ( null !== $ac->baseAmount ) {
			$this->amount( $el, 'BasisAmount', $ac->baseAmount );
		}
		$this->amount( $el, 'ActualAmount', $ac->amount );
		if ( '' !== $ac->reasonCode ) {
			$this->ram( $el, 'ReasonCode', $ac->reasonCode );
		}
		if ( '' !== $ac->reasonText ) {
			$this->ram( $el, 'Reason', $ac->reasonText );
		}
		// A document-level allowance/charge must state its own VAT category —
		// it feeds the VAT breakdown, same requirement as UBL's equivalent.
		if ( $documentLevel && null !== $ac->vatCategoryCode ) {
			$catTax = $this->ram( $el, 'CategoryTradeTax' );
			$this->ram( $catTax, 'TypeCode', 'VAT' );
			$this->ram( $catTax, 'CategoryCode', (string) $ac->vatCategoryCode );
			if ( VatCategory::OUT_OF_SCOPE !== $ac->vatCategoryCode && null !== $ac->vatPercent ) {
				$this->ram( $catTax, 'RateApplicablePercent', Num::percent( $ac->vatPercent ) );
			}
		}
	}

	private function buildMonetarySummation( DOMElement $settlement, Invoice $invoice ): void {
		$t   = $invoice->totals;
		$cur = $invoice->currency;
		$sum = $this->ram( $settlement, 'SpecifiedTradeSettlementHeaderMonetarySummation' );

		$this->amount( $sum, 'LineTotalAmount', $t->lineExtensionAmount );
		if ( $t->chargeTotalAmount > 0.0 ) {
			$this->amount( $sum, 'ChargeTotalAmount', $t->chargeTotalAmount );
		}
		if ( $t->allowanceTotalAmount > 0.0 ) {
			$this->amount( $sum, 'AllowanceTotalAmount', $t->allowanceTotalAmount );
		}
		$this->amount( $sum, 'TaxBasisTotalAmount', $t->taxExclusiveAmount );
		// The ONE amount that must carry currencyID (CII-DT-031 excludes only
		// TaxTotalAmount), because a document may state tax in a second currency.
		$this->amount( $sum, 'TaxTotalAmount', $t->taxTotalAmount )->setAttribute( 'currencyID', $cur );
		if ( ! Num::moneyEquals( $t->payableRoundingAmount, 0.0 ) ) {
			$this->amount( $sum, 'RoundingAmount', $t->payableRoundingAmount );
		}
		$this->amount( $sum, 'GrandTotalAmount', $t->taxInclusiveAmount );
		if ( $t->prepaidAmount > 0.0 ) {
			$this->amount( $sum, 'TotalPrepaidAmount', $t->prepaidAmount );
		}
		$this->amount( $sum, 'DuePayableAmount', $t->payableAmount );
	}

	/* ------------------------------------------------------------------ *
	 * Line item
	 * ------------------------------------------------------------------ */

	private function buildLineItem( DOMElement $transaction, InvoiceLine $line ): void {
		$item = $this->ram( $transaction, 'IncludedSupplyChainTradeLineItem' );

		$lineDoc = $this->ram( $item, 'AssociatedDocumentLineDocument' );
		$this->ram( $lineDoc, 'LineID', $line->id );
		if ( '' !== $line->note ) {
			$note = $this->ram( $lineDoc, 'IncludedNote' );
			$this->ram( $note, 'Content', $line->note );
		}

		$product = $this->ram( $item, 'SpecifiedTradeProduct' );
		if ( '' !== $line->standardItemId ) {
			$id = $this->ram( $product, 'GlobalID', $line->standardItemId );
			if ( '' !== $line->standardItemScheme ) {
				$id->setAttribute( 'schemeID', $line->standardItemScheme );
			}
		}
		if ( '' !== $line->sellerItemId ) {
			$this->ram( $product, 'SellerAssignedID', $line->sellerItemId );
		}
		$this->ram( $product, 'Name', $line->name );
		if ( '' !== $line->description ) {
			$this->ram( $product, 'Description', $line->description );
		}

		$agreement = $this->ram( $item, 'SpecifiedLineTradeAgreement' );
		$price     = $this->ram( $agreement, 'NetPriceProductTradePrice' );
		$this->amount( $price, 'ChargeAmount', $line->netPriceAmount );
		if ( $line->baseQuantity > 0.0 && ! Num::moneyEquals( $line->baseQuantity, 1.0 ) ) {
			$base = $this->ram( $price, 'BasisQuantity', Num::quantity( $line->baseQuantity ) );
			$base->setAttribute( 'unitCode', $line->unitCode );
		}

		$delivery = $this->ram( $item, 'SpecifiedLineTradeDelivery' );
		$qty      = $this->ram( $delivery, 'BilledQuantity', Num::quantity( $line->quantity ) );
		$qty->setAttribute( 'unitCode', $line->unitCode );

		$lineSettlement = $this->ram( $item, 'SpecifiedLineTradeSettlement' );
		$tax            = $this->ram( $lineSettlement, 'ApplicableTradeTax' );
		$this->ram( $tax, 'TypeCode', 'VAT' );
		$this->ram( $tax, 'CategoryCode', $line->vatCategoryCode );
		if ( VatCategory::OUT_OF_SCOPE !== $line->vatCategoryCode ) {
			$this->ram( $tax, 'RateApplicablePercent', Num::percent( $line->vatPercent ) );
		}
		foreach ( $line->allowanceCharges as $ac ) {
			$this->buildAllowanceCharge( $lineSettlement, $ac, false );
		}
		$lineSum = $this->ram( $lineSettlement, 'SpecifiedTradeSettlementLineMonetarySummation' );
		$this->amount( $lineSum, 'LineTotalAmount', $line->lineExtensionAmount );
	}

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

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

	private function ram( DOMElement $parent, string $name, ?string $value = null ): DOMElement {
		// Value as a text node, not via createElementNS()'s value argument, for
		// the same reason as UblBisSerializer: a bare "&" would otherwise warn
		// and truncate instead of being escaped on serialization.
		$el = $this->doc->createElementNS( self::NS_RAM, 'ram:' . $name );
		if ( null !== $value && '' !== $value ) {
			$el->appendChild( $this->doc->createTextNode( $value ) );
		}
		$parent->appendChild( $el );
		return $el;
	}

	/**
	 * An amount element, deliberately WITHOUT a currencyID attribute.
	 *
	 * This is the opposite of UBL, where every amount carries currencyID. The
	 * official EN 16931 CII schematron forbids it: rule CII-DT-031
	 * (`not(@currencyID)`) is applied to the context
	 * `//ram:*[ends-with(name(), 'Amount') and not (self::ram:TaxTotalAmount)]`
	 * — i.e. every amount EXCEPT TaxTotalAmount, which is the sole element
	 * allowed to carry it (a document may state tax in a second currency).
	 * The XSD permits the attribute everywhere, so this is invisible to
	 * schemaValidate() and to atgp/factur-x's embed-time check — only the
	 * schematron catches it, and that's what French PPF/PDP portals run.
	 * Callers that need the attribute set it explicitly on the returned node.
	 */
	private function amount( DOMElement $parent, string $name, float $value ): DOMElement {
		return $this->ram( $parent, $name, Num::amount( $value ) );
	}

	/** udt:DateTimeType — a DateTimeString child with format="102" (CCYYMMDD), the CII date convention. */
	private function udtDate( DOMElement $parent, string $isoDate ): void {
		$compact = str_replace( '-', '', $isoDate ); // "2026-08-17" -> "20260817".
		$el      = $this->doc->createElementNS( self::NS_UDT, 'udt:DateTimeString', $compact );
		$el->setAttribute( 'format', '102' );
		$parent->appendChild( $el );
	}

	/**
	 * qdt:FormattedDateTimeType — same CCYYMMDD convention as {@see udtDate()}
	 * but in the qdt: namespace, not udt:. Used only for
	 * InvoiceReferencedDocument/FormattedIssueDateTime; every other CII date
	 * in this profile (IssueDateTime, DueDateDateTime) is udt:.
	 */
	private function qdtDate( DOMElement $parent, string $isoDate ): void {
		$compact = str_replace( '-', '', $isoDate );
		$el      = $this->doc->createElementNS( self::NS_QDT, 'qdt:DateTimeString', $compact );
		$el->setAttribute( 'format', '102' );
		$parent->appendChild( $el );
	}

	/** udt:IndicatorType — an Indicator child in the udt: namespace, not ram:. */
	private function udtIndicator( DOMElement $parent, bool $value ): void {
		$el = $this->doc->createElementNS( self::NS_UDT, 'udt:Indicator', $value ? 'true' : 'false' );
		$parent->appendChild( $el );
	}
}
