<?php
/**
 * UBL 2.1 Invoice generator.
 *
 * @package Harbor_EU_EInvoicing_For_WooCommerce
 */

defined( 'ABSPATH' ) || exit;

class HEIN_UBL_Generator extends HEIN_Generator {

	protected const NS_ROOT = 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2';
	protected const NS_CAC  = 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2';
	protected const NS_CBC  = 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2';

	protected ?HEIN_XML_Writer $xml = null;

	public function render( array $invoice ): array {
		return [
			'content'   => $this->build( $this->prepare( $invoice ) ),
			'mime'      => 'application/xml',
			'extension' => 'xml',
		];
	}

	public static function label(): string {
		return __( 'UBL 2.1 / Peppol BIS Billing 3.0', 'harbor-eu-einvoicing-for-woocommerce' );
	}

	protected function prepare( array $invoice ): array {
		return $invoice;
	}

	protected function customization_id( array $invoice ): string {
		return 'en16931' === ( $invoice['profile'] ?? 'peppol' )
			? 'urn:cen.eu:en16931:2017'
			: 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0';
	}

	protected function profile_id( array $invoice ): ?string {
		return 'en16931' === ( $invoice['profile'] ?? 'peppol' )
			? null
			: 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0';
	}

	protected function writer(): HEIN_XML_Writer {
		return new HEIN_XML_Writer(
			'Invoice',
			self::NS_ROOT,
			[
				'cac' => self::NS_CAC,
				'cbc' => self::NS_CBC,
			]
		);
	}

	/**
	 * InvoiceType is an xsd:sequence, so this order is mandatory regardless of schematron
	 */
	protected function build( array $invoice ): string {
		$this->xml = $this->writer();

		$root     = $this->xml->root();
		$currency = (string) $invoice['currency'];

		$this->xml->el( $root, 'cbc:CustomizationID', $this->customization_id( $invoice ) );
		$this->xml->el( $root, 'cbc:ProfileID', $this->profile_id( $invoice ) );
		$this->xml->el( $root, 'cbc:ID', $invoice['invoice_number'] );
		$this->xml->el( $root, 'cbc:IssueDate', $invoice['issue_date'] );
		$this->xml->el( $root, 'cbc:DueDate', $invoice['due_date'] );
		$this->xml->el( $root, 'cbc:InvoiceTypeCode', $invoice['type_code'] );
		$this->xml->el( $root, 'cbc:DocumentCurrencyCode', $currency );
		$this->xml->el( $root, 'cbc:BuyerReference', $invoice['buyer_reference'] );

		$this->xml->el( $this->xml->node( $root, 'cac:OrderReference' ), 'cbc:ID', $invoice['order_reference'] );

		$this->party( $root, 'cac:AccountingSupplierParty', $invoice['seller'], $invoice );
		$this->party( $root, 'cac:AccountingCustomerParty', $invoice['buyer'], $invoice );
		$this->delivery( $root, $invoice );
		$this->payment_means( $root, $invoice );
		$this->payment_terms( $root, $invoice );

		foreach ( $invoice['allowances'] as $allowance ) {
			$this->allowance_charge( $root, $allowance, false, $invoice );
		}

		foreach ( $invoice['charges'] as $charge ) {
			$this->allowance_charge( $root, $charge, true, $invoice );
		}

		$this->tax_total( $root, $invoice );
		$this->monetary_total( $root, $invoice );

		foreach ( $invoice['lines'] as $line ) {
			$this->line( $root, $line, $invoice );
		}

		return $this->xml->save();
	}

	protected function party( DOMElement $parent, string $qname, array $party, array $invoice ): void {
		$el = $this->xml->node( $this->xml->node( $parent, $qname ), 'cac:Party' );

		$this->xml->el(
			$el,
			'cbc:EndpointID',
			$party['endpoint_id'] ?? null,
			[ 'schemeID' => (string) ( $party['endpoint_scheme'] ?? '' ) ]
		);

		$this->xml->el(
			$this->xml->node( $el, 'cac:PartyName' ),
			'cbc:Name',
			$party['trading_name'] ?? $party['name']
		);

		$this->address( $el, 'cac:PostalAddress', (array) ( $party['address'] ?? [] ) );

		// BR-O-02/03/04 forbid the VAT id on BOTH parties in an 'O' document, where it arrives null
		$this->tax_scheme( $el, $party['vat_id'] ?? null, HEIN_Codes::TAX_SCHEME );
		$this->tax_scheme( $el, $party['tax_registration'] ?? null, 'FC' );

		// BR-06/BR-07 assert on RegistrationName, NOT on cac:PartyName
		$legal = $this->xml->node( $el, 'cac:PartyLegalEntity' );
		$this->xml->el( $legal, 'cbc:RegistrationName', $party['name'] ?? null );

		// BR-CO-26 needs BT-29, BT-30 or BT-31, so a seller with no VAT id relies on this. The scheme is
		// already validated against the country in HEIN_Admin_Settings::seller(), and el() drops an empty
		// attribute, so a country with no pinned scheme emits a bare cbc:CompanyID
		$this->xml->el(
			$legal,
			'cbc:CompanyID',
			$party['legal_id'] ?? null,
			[ 'schemeID' => (string) ( $party['legal_id_scheme'] ?? '' ) ]
		);

		$contact = $this->xml->node( $el, 'cac:Contact' );
		$this->xml->el( $contact, 'cbc:Name', $party['contact']['name'] ?? null );
		$this->xml->el( $contact, 'cbc:Telephone', $party['contact']['phone'] ?? null );
		$this->xml->el( $contact, 'cbc:ElectronicMail', $party['contact']['email'] ?? null );
	}

	private function tax_scheme( DOMElement $parent, ?string $company_id, string $scheme ): void {
		if ( null === $company_id || '' === trim( $company_id ) ) {
			return;
		}

		$el = $this->xml->node( $parent, 'cac:PartyTaxScheme' );

		// UBL-CR-652: cbc:CompanyID here must carry no @schemeID
		$this->xml->el( $el, 'cbc:CompanyID', $company_id );
		$this->xml->el( $this->xml->node( $el, 'cac:TaxScheme' ), 'cbc:ID', $scheme );
	}

	protected function address( DOMElement $parent, string $qname, array $address ): void {
		$el = $this->xml->node( $parent, $qname );

		$this->xml->el( $el, 'cbc:StreetName', $address['street'] ?? null );
		$this->xml->el( $el, 'cbc:AdditionalStreetName', $address['street2'] ?? null );
		$this->xml->el( $el, 'cbc:CityName', $address['city'] ?? null );
		$this->xml->el( $el, 'cbc:PostalZone', $address['postcode'] ?? null );
		$this->xml->el( $el, 'cbc:CountrySubentity', $address['subentity'] ?? null );
		$this->xml->el(
			$this->xml->node( $el, 'cac:Country' ),
			'cbc:IdentificationCode',
			$address['country'] ?? null
		);
	}

	protected function delivery( DOMElement $parent, array $invoice ): void {
		$delivery = $invoice['delivery'] ?? null;

		if ( ! is_array( $delivery ) ) {
			return;
		}

		$el = $this->xml->node( $parent, 'cac:Delivery' );

		$this->xml->el( $el, 'cbc:ActualDeliveryDate', $delivery['actual_date'] ?? null );
		$this->address(
			$this->xml->node( $el, 'cac:DeliveryLocation' ),
			'cac:Address',
			(array) ( $delivery['address'] ?? [] )
		);
	}

	protected function payment_means( DOMElement $parent, array $invoice ): void {
		$payment = (array) $invoice['payment'];
		$iban    = trim( (string) ( $payment['iban'] ?? '' ) );
		$el      = $this->xml->node( $parent, 'cac:PaymentMeans' );

		$this->xml->el(
			$el,
			'cbc:PaymentMeansCode',
			$payment['means_code'] ?? null,
			[ 'name' => (string) ( $payment['means_name'] ?? '' ) ]
		);
		$this->xml->el( $el, 'cbc:PaymentID', $payment['reference'] ?? null );

		// BR-61: PayeeFinancialAccount/cbc:ID must exist iff means_code is 30 or 58, which
		// HEIN_Codes::payment_means_code() only returns when an IBAN is configured
		if ( '' === $iban ) {
			return;
		}

		$account = $this->xml->node( $el, 'cac:PayeeFinancialAccount' );

		$this->xml->el( $account, 'cbc:ID', $iban );
		$this->xml->el( $account, 'cbc:Name', $payment['account_name'] ?? null );
		$this->xml->el(
			$this->xml->node( $account, 'cac:FinancialInstitutionBranch' ),
			'cbc:ID',
			$payment['bic'] ?? null
		);
	}

	protected function payment_terms( DOMElement $parent, array $invoice ): void {
		$this->xml->el(
			$this->xml->node( $parent, 'cac:PaymentTerms' ),
			'cbc:Note',
			$invoice['payment']['terms_note'] ?? null
		);
	}

	protected function allowance_charge( DOMElement $parent, array $ac, bool $is_charge, array $invoice ): void {
		$el = $this->xml->node( $parent, 'cac:AllowanceCharge' );

		// PEPPOL-R043 requires the lowercase boolean lexical form
		$this->xml->el( $el, 'cbc:ChargeIndicator', $is_charge ? 'true' : 'false' );
		$this->xml->el( $el, 'cbc:AllowanceChargeReasonCode', $ac['reason_code'] ?? null );
		$this->xml->el( $el, 'cbc:AllowanceChargeReason', $ac['reason'] ?? null );
		$this->xml->amount( $el, 'cbc:Amount', $ac['amount'] ?? null, (string) $invoice['currency'] );

		// BR-32/BR-37: a DOCUMENT-level allowance or charge must carry cac:TaxCategory
		$this->tax_category_el( $el, 'cac:TaxCategory', (array) $ac['tax'], true );
	}

	/**
	 * PEPPOL-EN16931-R053 permits exactly ONE cac:TaxTotal carrying cac:TaxSubtotal children
	 */
	protected function tax_total( DOMElement $parent, array $invoice ): void {
		$currency = (string) $invoice['currency'];
		$el       = $this->xml->node( $parent, 'cac:TaxTotal' );

		$this->xml->amount( $el, 'cbc:TaxAmount', $invoice['totals']['tax_total'], $currency );

		foreach ( $invoice['tax_breakdown'] as $row ) {
			$subtotal = $this->xml->node( $el, 'cac:TaxSubtotal' );

			$this->xml->amount( $subtotal, 'cbc:TaxableAmount', $row['taxable_amount'], $currency );
			$this->xml->amount( $subtotal, 'cbc:TaxAmount', $row['tax_amount'], $currency );
			$this->tax_category_el( $subtotal, 'cac:TaxCategory', $row, true );
		}
	}

	/**
	 * $with_exemption is false for the line's cac:ClassifiedTaxCategory, which carries ID + Percent
	 * + TaxScheme only; the exemption fields belong to BG-23 and to document-level allowances
	 */
	protected function tax_category_el( DOMElement $parent, string $qname, array $tax, bool $with_exemption ): void {
		$el = $this->xml->node( $parent, $qname );

		$this->xml->el( $el, 'cbc:ID', $tax['category'] ?? null );

		// BR-O-05 forbids cbc:Percent on category 'O', where percent arrives null
		$this->xml->el( $el, 'cbc:Percent', $tax['percent'] ?? null );

		if ( $with_exemption ) {
			$this->xml->el( $el, 'cbc:TaxExemptionReasonCode', $tax['exemption_code'] ?? null );
			$this->xml->el( $el, 'cbc:TaxExemptionReason', $tax['exemption_reason'] ?? null );
		}

		$this->xml->el( $this->xml->node( $el, 'cac:TaxScheme' ), 'cbc:ID', HEIN_Codes::TAX_SCHEME );
	}

	protected function monetary_total( DOMElement $parent, array $invoice ): void {
		$currency = (string) $invoice['currency'];
		$totals   = (array) $invoice['totals'];
		$el       = $this->xml->node( $parent, 'cac:LegalMonetaryTotal' );

		$this->xml->amount( $el, 'cbc:LineExtensionAmount', $totals['line_total'], $currency );
		$this->xml->amount( $el, 'cbc:TaxExclusiveAmount', $totals['tax_exclusive'], $currency );
		$this->xml->amount( $el, 'cbc:TaxInclusiveAmount', $totals['tax_inclusive'], $currency );
		$this->xml->amount( $el, 'cbc:AllowanceTotalAmount', $totals['allowance_total'], $currency );
		$this->xml->amount( $el, 'cbc:ChargeTotalAmount', $totals['charge_total'], $currency );
		$this->xml->amount( $el, 'cbc:PrepaidAmount', $totals['prepaid'], $currency );
		$this->xml->amount( $el, 'cbc:PayableRoundingAmount', $totals['rounding'], $currency );
		$this->xml->amount( $el, 'cbc:PayableAmount', $totals['payable'], $currency );
	}

	protected function line( DOMElement $parent, array $line, array $invoice ): void {
		$currency = (string) $invoice['currency'];
		$unit     = (string) $line['unit_code'];
		$el       = $this->xml->node( $parent, 'cac:InvoiceLine' );

		$this->xml->el( $el, 'cbc:ID', $line['id'] );
		$this->xml->qty( $el, 'cbc:InvoicedQuantity', $line['quantity'], $unit );
		$this->xml->amount( $el, 'cbc:LineExtensionAmount', $line['net_amount'], $currency );

		$item = $this->xml->node( $el, 'cac:Item' );

		$this->xml->el( $item, 'cbc:Description', $line['description'] ?? null );
		$this->xml->el( $item, 'cbc:Name', $line['name'] );
		$this->xml->el(
			$this->xml->node( $item, 'cac:SellersItemIdentification' ),
			'cbc:ID',
			$line['seller_item_id'] ?? null
		);
		$this->tax_category_el( $item, 'cac:ClassifiedTaxCategory', (array) $line['tax'], false );

		$price = $this->xml->node( $el, 'cac:Price' );

		$this->xml->amount( $price, 'cbc:PriceAmount', $line['price_amount'], $currency );

		// PEPPOL-R130: BaseQuantity @unitCode must equal InvoicedQuantity @unitCode
		$this->xml->qty( $price, 'cbc:BaseQuantity', $line['base_quantity'], $unit );
	}
}
