<?php
/**
 * Order to normalized invoice array.
 *
 * @package Harbor_EU_EInvoicing_For_WooCommerce
 */

defined( 'ABSPATH' ) || exit;

class HEIN_Invoice_Data {

	/**
	 * Priority used by a zero-rated document charge or allowance to inherit the dominant line
	 * category, so one supply never carries two contradictory exemption groups
	 */
	private const CATEGORY_PRIORITY = [ 'K', 'AE', 'G', 'E', 'Z', 'O' ];

	/**
	 * Every validation happens before HEIN_Invoice_Number::for_order(), which is the last mutation:
	 * a failed generation must not burn a legal number or put a gap in the series
	 *
	 * @throws HEIN_Invoice_Exception On any failed precondition.
	 */
	public static function from_order( WC_Order $order ): array {
		$settings = HEIN_Admin_Settings::all();
		$profile  = 'en16931' === ( $settings['profile'] ?? 'peppol' ) ? 'en16931' : 'peppol';
		$currency = (string) $order->get_currency();

		$seller = HEIN_Admin_Settings::seller();
		$buyer  = self::buyer( $order );

		$seller_country = strtoupper( (string) ( $seller['address']['country'] ?? '' ) );
		$buyer_country  = strtoupper( (string) ( $buyer['address']['country'] ?? '' ) );
		$seller_vat     = trim( (string) ( $seller['vat_id'] ?? '' ) );
		$buyer_vat      = (string) ( $buyer['vat_id'] ?? '' );

		if ( 'peppol' === $profile && '' === $buyer['endpoint_id'] ) {
			throw new HEIN_Invoice_Exception(
				esc_html__(
					'The Peppol profile requires a buyer electronic address. Add a valid VAT number to the order, or switch the profile to EN 16931 for B2C sales.',
					'harbor-eu-einvoicing-for-woocommerce'
				),
				'no_buyer_endpoint'
			);
		}

		$rate_map = self::rate_map( $order );

		$context = [
			'seller_country'  => $seller_country,
			'seller_vat'      => $seller_vat,
			'buyer_country'   => $buyer_country,
			'buyer_vat'       => $buyer_vat,
			'buyer_vat_valid' => '' !== $buyer_vat,
			'is_service'      => false,
			'order'           => $order,
			'item'            => null,
		];

		list( $lines, $credits ) = self::lines( $order, $rate_map, $context );

		if ( ! $lines ) {
			throw new HEIN_Invoice_Exception(
				esc_html__( 'This order has no product lines, so it cannot be invoiced.', 'harbor-eu-einvoicing-for-woocommerce' ),
				'no_lines'
			);
		}

		list( $allowances, $charges ) = self::allowances_and_charges( $order, $rate_map, $context, $lines, $credits );

		if ( '' === $seller_vat ) {
			self::assert_o_representable( $lines, $allowances, $charges, $seller_country, $buyer_country );
			$buyer['vat_id'] = null;
		}

		$breakdown = self::tax_breakdown( $lines, $allowances, $charges );
		$totals    = self::totals( $order, $lines, $allowances, $charges, $breakdown );

		$has_k      = in_array( 'K', wp_list_pluck( $breakdown, 'category' ), true );
		$issue_date = HEIN_Invoice_Number::issue_date( $order );
		$delivery   = self::delivery( $order, $issue_date, $has_k );
		$payment    = self::payment( $order, $settings );
		$due_days   = max( 0, (int) ( $settings['payment']['due_days'] ?? 0 ) );

		$number               = HEIN_Invoice_Number::for_order( $order, $issue_date );
		$payment['reference'] = $number;

		$invoice = [
			'profile'         => $profile,
			'invoice_number'  => $number,
			'issue_date'      => $issue_date,
			'due_date'        => self::due_date( $issue_date, $due_days, $totals['payable'] ),
			'type_code'       => HEIN_Codes::TYPE_CODE_INVOICE,
			'currency'        => $currency,
			'buyer_reference' => self::buyer_reference( $order ),
			'order_reference' => (string) $order->get_order_number(),
			'delivery'        => $delivery,
			'seller'          => $seller,
			'buyer'           => $buyer,
			'payment'         => $payment,
			'lines'           => $lines,
			'allowances'      => $allowances,
			'charges'         => $charges,
			'tax_breakdown'   => $breakdown,
			'totals'          => $totals,
			'attachments'     => [],
			'meta'            => [
				'order_id'        => $order->get_id(),
				'order_number'    => (string) $order->get_order_number(),
				'seller_country'  => $seller_country,
				'buyer_country'   => $buyer_country,
				'buyer_vat_valid' => '' !== $buyer_vat,
				'is_b2b'          => '' !== $buyer_vat || '' !== trim( (string) $order->get_billing_company() ),
				'is_cross_border' => $seller_country !== $buyer_country,
				'has_k'           => $has_k,
				'refunded_total'  => HEIN_Money::amount( (float) $order->get_total_refunded() ),
			],
		];

		return (array) apply_filters( 'hein_invoice_data', $invoice, $order );
	}

	public static function buyer( WC_Order $order ): array {
		$country = strtoupper( (string) $order->get_billing_country() );
		$name    = trim( (string) $order->get_billing_company() );

		if ( '' === $name ) {
			$name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
		}

		if ( '' === $name ) {
			throw new HEIN_Invoice_Exception(
				esc_html__( 'The order has no billing name or company, which every invoice must carry.', 'harbor-eu-einvoicing-for-woocommerce' ),
				'no_buyer_name'
			);
		}

		$address = [
			'street'    => trim( (string) $order->get_billing_address_1() ),
			'street2'   => self::or_null( (string) $order->get_billing_address_2() ),
			'city'      => trim( (string) $order->get_billing_city() ),
			'postcode'  => trim( (string) $order->get_billing_postcode() ),
			'subentity' => self::or_null( (string) $order->get_billing_state() ),
			'country'   => $country,
		];

		if ( ! self::address_complete( $address ) ) {
			throw new HEIN_Invoice_Exception(
				esc_html__( 'The billing address is missing a street, city, postcode or country.', 'harbor-eu-einvoicing-for-woocommerce' ),
				'incomplete_buyer_address'
			);
		}

		$typed  = HEIN_Checkout_Fields::vat( $order );
		$vat_id = HEIN_VAT::is_valid_for( $typed, $country ) ? HEIN_VAT::canonical( $typed, $country ) : null;

		// The scheme belongs to the country that ISSUED the VAT id, which is not always the country
		// billed: a VAT group or a branch is routinely invoiced at an address in another member state
		$endpoint = null !== $vat_id ? HEIN_Codes::endpoint_for_vat( $vat_id, HEIN_VAT::country( $vat_id ) ) : [];
		$endpoint = (array) apply_filters( 'hein_buyer_endpoint', $endpoint, $order );

		return [
			'name'            => $name,
			'trading_name'    => null,
			'vat_id'          => $vat_id,
			'endpoint_id'     => (string) ( $endpoint['id'] ?? '' ),
			'endpoint_scheme' => (string) ( $endpoint['scheme'] ?? '' ),
			'contact'         => [
				'name'  => self::or_null( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ),
				'phone' => self::or_null( (string) $order->get_billing_phone() ),
				'email' => self::or_null( (string) $order->get_billing_email() ),
			],
			'address'         => $address,
		];
	}

	/**
	 * The order's own tax items are the immutable snapshot taken at checkout. WC_Tax::_get_tax_rate()
	 * reads the live table: it returns null for a deleted rate and the CURRENT rate after an edit,
	 * which would silently reclassify historical orders on a regeneration
	 */
	public static function rate_map( WC_Order $order ): array {
		$map = [];

		foreach ( $order->get_items( 'tax' ) as $tax_item ) {
			$rate_id = (int) $tax_item->get_rate_id();
			$percent = $tax_item->get_rate_percent();

			if ( null === $percent && method_exists( 'WC_Tax', 'get_rate_percent_value' ) ) {
				$percent = WC_Tax::get_rate_percent_value( $rate_id );
			}

			$map[ $rate_id ] = [
				'percent'  => (float) $percent,
				'compound' => (bool) $tax_item->get_compound(),
			];
		}

		return $map;
	}

	/**
	 * EN 16931 allows one tax category per line, so two non-zero rates or a compound rate are not
	 * representable and summing them would fabricate a rate that exists nowhere
	 *
	 * @throws HEIN_Invoice_Exception When the line carries compound or multiple non-zero rates.
	 */
	public static function item_tax_percent( WC_Order_Item $item, array $rate_map ): float {
		$taxes    = $item->get_taxes();
		$percents = [];

		foreach ( (array) ( $taxes['total'] ?? [] ) as $rate_id => $amount ) {
			if ( '' === $amount || null === $amount ) {
				continue;
			}

			$rate = $rate_map[ (int) $rate_id ] ?? null;

			if ( null === $rate ) {
				continue;
			}

			if ( $rate['compound'] ) {
				throw new HEIN_Invoice_Exception(
					esc_html__( 'This order uses a compound tax rate, which cannot be expressed on an EN 16931 invoice.', 'harbor-eu-einvoicing-for-woocommerce' ),
					'unsupported_tax_rates'
				);
			}

			if ( abs( $rate['percent'] ) > 0.0 ) {
				$percents[ (string) round( $rate['percent'], 4 ) ] = (float) $rate['percent'];
			}
		}

		if ( count( $percents ) > 1 ) {
			throw new HEIN_Invoice_Exception(
				esc_html__( 'A line on this order carries two different VAT rates, which cannot be expressed on an EN 16931 invoice.', 'harbor-eu-einvoicing-for-woocommerce' ),
				'unsupported_tax_rates'
			);
		}

		$percent = $percents ? (float) reset( $percents ) : 0.0;
		$order   = method_exists( $item, 'get_order' ) ? $item->get_order() : null;

		return (float) apply_filters( 'hein_item_tax_percent', $percent, $item, $order );
	}

	/**
	 * The VAT WooCommerce actually charged on this item, summed across its rate rows at full
	 * precision. BT-117 must equal what the customer paid, and with default per-line tax rounding the
	 * sum of these diverges from a single taxable x rate recompute, which the breakdown reconciles to
	 */
	public static function item_tax_amount( WC_Order_Item $item, array $rate_map ): float {
		$sum   = 0.0;
		$taxes = $item->get_taxes();

		foreach ( (array) ( $taxes['total'] ?? [] ) as $rate_id => $amount ) {
			if ( '' === $amount || null === $amount ) {
				continue;
			}

			if ( ! isset( $rate_map[ (int) $rate_id ] ) ) {
				continue;
			}

			$sum += (float) $amount;
		}

		return $sum;
	}

	public static function is_service( WC_Order_Item $item ): bool {
		$product    = method_exists( $item, 'get_product' ) ? $item->get_product() : false;
		$is_service = $product ? ( $product->is_virtual() || $product->is_downloadable() ) : false;

		return (bool) apply_filters( 'hein_is_service', $is_service, $item );
	}

	/**
	 * Strictly ordered: the first branch dominates. A positive effective rate is always 'S',
	 * regardless of VAT numbers, so it is impossible to emit a reverse-charge invoice for money
	 * on which VAT was actually collected
	 */
	public static function tax_category( float $percent, array $context ): array {
		$settings = HEIN_Admin_Settings::all();

		if ( $percent > 0 ) {
			$tax = self::category_ref( 'S', $percent );
		} elseif ( '' === (string) $context['seller_country'] || '' === (string) $context['seller_vat'] ) {
			$tax = self::category_ref( 'O', 0.0 );
		} elseif ( ! HEIN_Codes::is_eu( (string) $context['buyer_country'] ) ) {
			$tax = self::category_ref( 'G', 0.0 );
		} elseif (
			$context['buyer_country'] !== $context['seller_country']
			&& ! empty( $context['buyer_vat_valid'] )
		) {
			$tax = self::category_ref( empty( $context['is_service'] ) ? 'K' : 'AE', 0.0 );
		} else {
			$tax = self::category_ref( 'E' === ( $settings['zero_rate_category'] ?? 'Z' ) ? 'E' : 'Z', 0.0 );

			if ( 'E' === $tax['category'] ) {
				$tax['exemption_code']   = self::or_null( (string) ( $settings['exemption_code'] ?? '' ) );
				$tax['exemption_reason'] = self::or_null( (string) ( $settings['exemption_reason'] ?? '' ) );

				// BR-E-10 is fatal unless at least one of the two is present
				if ( null === $tax['exemption_code'] && null === $tax['exemption_reason'] ) {
					$tax['exemption_reason'] = __( 'Exempt from VAT', 'harbor-eu-einvoicing-for-woocommerce' );
				}
			}
		}

		return (array) apply_filters( 'hein_tax_category', $tax, $context );
	}

	public static function delivery( WC_Order $order, string $issue_date, bool $required ): ?array {
		$address = [
			'street'    => trim( (string) $order->get_shipping_address_1() ),
			'street2'   => self::or_null( (string) $order->get_shipping_address_2() ),
			'city'      => trim( (string) $order->get_shipping_city() ),
			'postcode'  => trim( (string) $order->get_shipping_postcode() ),
			'subentity' => self::or_null( (string) $order->get_shipping_state() ),
			'country'   => strtoupper( (string) $order->get_shipping_country() ),
		];

		if ( ! self::address_complete( $address ) ) {
			$address = [
				'street'    => trim( (string) $order->get_billing_address_1() ),
				'street2'   => self::or_null( (string) $order->get_billing_address_2() ),
				'city'      => trim( (string) $order->get_billing_city() ),
				'postcode'  => trim( (string) $order->get_billing_postcode() ),
				'subentity' => self::or_null( (string) $order->get_billing_state() ),
				'country'   => strtoupper( (string) $order->get_billing_country() ),
			];
		}

		if ( ! self::address_complete( $address ) ) {
			if ( $required ) {
				throw new HEIN_Invoice_Exception(
					esc_html__( 'An intra-community supply needs a complete delivery address.', 'harbor-eu-einvoicing-for-woocommerce' ),
					'incomplete_delivery_address'
				);
			}

			return null;
		}

		$date = $order->get_date_completed() ?? $order->get_date_paid() ?? $order->get_date_created();

		return [
			'actual_date' => $date ? $date->date( 'Y-m-d' ) : $issue_date,
			'address'     => $address,
		];
	}

	/**
	 * Returns the invoice lines and, separately, the negative-net items: BR-27 forbids a negative
	 * item net price, so store credit, gift card redemptions and deposits added as a negative-price
	 * product are handed to allowances_and_charges() to become document-level allowances
	 *
	 * @throws HEIN_Invoice_Exception When a line carries no quantity but does carry an amount.
	 */
	private static function lines( WC_Order $order, array $rate_map, array $context ): array {
		$lines   = [];
		$credits = [];

		foreach ( array_values( $order->get_items( 'line_item' ) ) as $item ) {
			$quantity = (float) $item->get_quantity();

			// WC returns full precision when tax_round_at_subtotal is on and BR-CO-10 is exact
			$net = HEIN_Money::amount( (float) $item->get_total() );

			if ( HEIN_Money::is_negative( $net ) ) {
				$credits[] = $item;
				continue;
			}

			// PEPPOL-EN16931-R120 allows 0.02 between the net amount and quantity x price, and no
			// price makes that hold for a quantity of zero
			if ( $quantity <= 0 && ! HEIN_Money::is_zero( $net ) ) {
				throw new HEIN_Invoice_Exception(
					sprintf(
						/* translators: %s: the order line's product name */
						esc_html__(
							'The line "%s" has an amount but no quantity, which cannot be expressed on an EN 16931 invoice.',
							'harbor-eu-einvoicing-for-woocommerce'
						),
						esc_html( (string) $item->get_name() )
					),
					'zero_quantity_line'
				);
			}

			$price   = HEIN_Money::price( $net, $quantity );
			$product = method_exists( $item, 'get_product' ) ? $item->get_product() : false;
			$sku     = $product ? trim( (string) $product->get_sku() ) : '';

			$lines[] = [
				'id'             => (string) ( count( $lines ) + 1 ),
				'name'           => (string) $item->get_name(),
				'description'    => null,
				'seller_item_id' => '' !== $sku ? $sku : null,
				'quantity'       => HEIN_Money::quantity( $quantity ),
				'unit_code'      => HEIN_Codes::unit_code( $item ),
				'net_amount'     => $net,
				'price_amount'   => $price['price'],
				'base_quantity'  => $price['base_quantity'],
				'tax_amount'     => self::item_tax_amount( $item, $rate_map ),
				'tax'            => self::tax_category(
					self::item_tax_percent( $item, $rate_map ),
					array_merge(
						$context,
						[
							'is_service' => self::is_service( $item ),
							'item'       => $item,
						]
					)
				),
			];
		}

		return [ $lines, $credits ];
	}

	/**
	 * Coupons produce nothing: WooCommerce already nets them into $item->get_total(). One charge per
	 * shipping item (there can be several, each with its own rate) and per positive fee, one allowance
	 * per negative fee and per negative-net product line
	 */
	private static function allowances_and_charges(
		WC_Order $order,
		array $rate_map,
		array $context,
		array $lines,
		array $credits
	): array {
		$allowances = [];
		$charges    = [];
		$dominant   = self::dominant_category( $lines );

		foreach ( $order->get_items( 'shipping' ) as $item ) {
			$amount = HEIN_Money::amount( (float) $item->get_total() );

			if ( HEIN_Money::is_zero( $amount ) ) {
				continue;
			}

			$name = trim( (string) $item->get_name() );

			$charges[] = array_merge(
				[
					'amount'      => HEIN_Money::abs( $amount ),
					'reason'      => '' !== $name ? $name : __( 'Shipping', 'harbor-eu-einvoicing-for-woocommerce' ),
					'reason_code' => HEIN_Codes::SHIPPING_REASON_CODE,
				],
				self::entry_tax( $item, $rate_map, $context, $dominant, HEIN_Money::abs( $amount ) )
			);
		}

		foreach ( $order->get_items( 'fee' ) as $item ) {
			$amount = HEIN_Money::amount( (float) $item->get_total() );

			if ( HEIN_Money::is_zero( $amount ) ) {
				continue;
			}

			if ( HEIN_Money::is_negative( $amount ) ) {
				$allowances[] = self::allowance( $item, $amount, $rate_map, $context, $dominant );
				continue;
			}

			$name = trim( (string) $item->get_name() );

			$charges[] = array_merge(
				[
					'amount'      => $amount,
					'reason'      => '' !== $name ? $name : __( 'Fee', 'harbor-eu-einvoicing-for-woocommerce' ),
					'reason_code' => HEIN_Codes::FEE_REASON_CODE,
				],
				self::entry_tax( $item, $rate_map, $context, $dominant, $amount )
			);
		}

		foreach ( $credits as $item ) {
			$allowances[] = self::allowance(
				$item,
				HEIN_Money::amount( (float) $item->get_total() ),
				$rate_map,
				$context,
				$dominant
			);
		}

		return [ $allowances, $charges ];
	}

	/**
	 * The breakdown applies the allowance's own sign, so BT-92/BT-96 carry magnitudes
	 */
	private static function allowance(
		WC_Order_Item $item,
		string $amount,
		array $rate_map,
		array $context,
		?array $dominant
	): array {
		$name = trim( (string) $item->get_name() );

		return array_merge(
			[
				'amount'      => HEIN_Money::abs( $amount ),
				'reason'      => '' !== $name ? $name : __( 'Discount', 'harbor-eu-einvoicing-for-woocommerce' ),
				'reason_code' => HEIN_Codes::DISCOUNT_REASON_CODE,
			],
			self::entry_tax( $item, $rate_map, $context, $dominant, HEIN_Money::abs( $amount ) )
		);
	}

	/**
	 * The category and the VAT amount of an allowance or charge have to be decided together. An
	 * untaxed store credit inherits the dominant category so it lands in the buyer's own VAT bucket,
	 * but inheriting a 21% category while contributing its own 0.00 of tax desynchronises that
	 * bucket's base from its tax and BR-S-09 then fails at the access point. Whenever the resolved
	 * rate is not the item's own rate, the tax is recomputed from the resolved rate
	 */
	private static function entry_tax(
		WC_Order_Item $item,
		array $rate_map,
		array $context,
		?array $dominant,
		string $amount
	): array {
		$percent = self::item_tax_percent( $item, $rate_map );
		$tax     = self::charge_tax( $item, $rate_map, $context, $dominant, $percent );

		$resolved = (float) ( $tax['percent'] ?? 0 );

		return [
			'tax'        => $tax,
			'tax_amount' => abs( $resolved - $percent ) > 0.0001
				? (float) HEIN_Money::tax_of( $amount, (string) $resolved )
				: abs( self::item_tax_amount( $item, $rate_map ) ),
		];
	}

	private static function charge_tax( WC_Order_Item $item, array $rate_map, array $context, ?array $dominant, float $percent ): array {
		if ( $percent > 0 || null === $dominant ) {
			return self::tax_category(
				$percent,
				array_merge(
					$context,
					[
						'is_service' => false,
						'item'       => $item,
					]
				)
			);
		}

		return $dominant;
	}

	private static function dominant_category( array $lines ): ?array {
		foreach ( self::CATEGORY_PRIORITY as $category ) {
			foreach ( $lines as $line ) {
				if ( $category === $line['tax']['category'] ) {
					return $line['tax'];
				}
			}
		}

		return null;
	}

	/**
	 * BR-S/Z/E/AE/IC/G-02 all require a seller VAT id, so with none the only legal category is 'O'
	 * and a document that actually charged tax cannot be represented at all. BR-O-05 forbids
	 * cbc:Percent while DE-R-014 (DE seller, DE buyer) requires it: unsatisfiable
	 *
	 * @throws HEIN_Invoice_Exception When the document is not representable as category 'O'.
	 */
	private static function assert_o_representable( array $lines, array $allowances, array $charges, string $seller_country, string $buyer_country ): void {
		foreach ( array_merge( $lines, $allowances, $charges ) as $row ) {
			if ( 'O' !== $row['tax']['category'] ) {
				throw new HEIN_Invoice_Exception(
					esc_html__( 'This order charged VAT but no seller VAT number is configured. Add one in the plugin settings.', 'harbor-eu-einvoicing-for-woocommerce' ),
					'tax_without_seller_vat'
				);
			}
		}

		if ( 'DE' === $seller_country && 'DE' === $buyer_country ) {
			throw new HEIN_Invoice_Exception(
				esc_html__( 'A German invoice to a German buyer cannot be issued without a seller VAT number.', 'harbor-eu-einvoicing-for-woocommerce' ),
				'o_not_representable'
			);
		}
	}

	private static function tax_breakdown( array $lines, array $allowances, array $charges ): array {
		$rows    = [];
		$entries = [];

		foreach ( $lines as $line ) {
			$entries[] = [ $line['tax'], $line['net_amount'], 1, (float) ( $line['tax_amount'] ?? 0.0 ) ];
		}

		foreach ( $charges as $charge ) {
			$entries[] = [ $charge['tax'], $charge['amount'], 1, (float) ( $charge['tax_amount'] ?? 0.0 ) ];
		}

		foreach ( $allowances as $allowance ) {
			$entries[] = [ $allowance['tax'], $allowance['amount'], -1, (float) ( $allowance['tax_amount'] ?? 0.0 ) ];
		}

		foreach ( $entries as $entry ) {
			list( $tax, $amount, $sign, $tax_amount ) = $entry;

			$key = $tax['category'] . '|' . (string) $tax['percent'];

			if ( ! isset( $rows[ $key ] ) ) {
				$rows[ $key ] = [
					'category'         => $tax['category'],
					'percent'          => $tax['percent'],
					'cents'            => 0,
					'tax'              => 0.0,
					'has_tax'          => false,
					'exemption_code'   => $tax['exemption_code'],
					'exemption_reason' => $tax['exemption_reason'],
				];
			}

			$rows[ $key ]['cents'] += $sign * HEIN_Money::cents( $amount );
			$rows[ $key ]['tax']   += $sign * $tax_amount;

			if ( $tax_amount > 0.0 ) {
				$rows[ $key ]['has_tax'] = true;
			}
		}

		$breakdown = [];

		foreach ( $rows as $row ) {
			$taxable = HEIN_Money::from_cents( $row['cents'] );

			// BT-117 is WooCommerce's actually-charged VAT, summed and rounded once so it matches the
			// receipt. Only an order that carried no tax figure for a rated category (imported, or
			// built by hand) falls back to the taxable x rate recompute
			$tax_amount = HEIN_Money::amount( $row['tax'] );

			if ( ! $row['has_tax'] && (float) $row['percent'] > 0.0 && ! HEIN_Money::is_zero( $taxable ) ) {
				$tax_amount = HEIN_Money::tax_of( $taxable, (string) ( $row['percent'] ?? '0' ) );
			}

			$breakdown[] = [
				'category'         => $row['category'],
				'percent'          => $row['percent'],
				'taxable_amount'   => $taxable,
				'tax_amount'       => $tax_amount,
				'exemption_code'   => $row['exemption_code'],
				'exemption_reason' => $row['exemption_reason'],
			];
		}

		usort( $breakdown, [ __CLASS__, 'compare_breakdown' ] );

		return $breakdown;
	}

	private static function compare_breakdown( array $a, array $b ): int {
		$category = strcmp( (string) $a['category'], (string) $b['category'] );

		return 0 !== $category ? $category : ( (float) $b['percent'] <=> (float) $a['percent'] );
	}

	/**
	 * BT-114 is a cash-rounding field. An unbounded VAT discrepancy hidden there is exactly what an
	 * access point or a tax authority flags, so it is bounded and thrown rather than absorbed
	 *
	 * @throws HEIN_Invoice_Exception When the rounding delta exceeds the tolerance.
	 */
	private static function totals( WC_Order $order, array $lines, array $allowances, array $charges, array $breakdown ): array {
		$line_total      = HEIN_Money::sum( wp_list_pluck( $lines, 'net_amount' ) );
		$allowance_total = HEIN_Money::sum( wp_list_pluck( $allowances, 'amount' ) );
		$charge_total    = HEIN_Money::sum( wp_list_pluck( $charges, 'amount' ) );
		$tax_exclusive   = HEIN_Money::subtract( HEIN_Money::sum( [ $line_total, $charge_total ] ), $allowance_total );
		$tax_total       = HEIN_Money::sum( wp_list_pluck( $breakdown, 'tax_amount' ) );
		$tax_inclusive   = HEIN_Money::sum( [ $tax_exclusive, $tax_total ] );

		$order_total = HEIN_Money::amount( (float) $order->get_total() );
		$rounding    = HEIN_Money::subtract( $order_total, $tax_inclusive );
		$tolerance   = (float) apply_filters( 'hein_rounding_tolerance', 0.05 );

		if ( abs( HEIN_Money::cents( $rounding ) ) > abs( HEIN_Money::cents( $tolerance ) ) ) {
			throw new HEIN_Invoice_Exception(
				sprintf(
					/* translators: %s: the rounding difference, e.g. -0.12 */
					esc_html__( 'The recomputed VAT differs from the order total by %s, which is too much to record as a rounding adjustment.', 'harbor-eu-einvoicing-for-woocommerce' ),
					esc_html( $rounding )
				),
				'rounding_out_of_bounds'
			);
		}

		$prepaid = $order->get_date_paid() ? $order_total : null;
		$payable = HEIN_Money::subtract(
			HEIN_Money::sum( [ $tax_inclusive, $rounding ] ),
			null !== $prepaid ? $prepaid : '0.00'
		);

		return [
			'line_total'      => $line_total,
			'allowance_total' => $allowances ? $allowance_total : null,
			'charge_total'    => $charges ? $charge_total : null,
			'tax_exclusive'   => $tax_exclusive,
			'tax_total'       => $tax_total,
			'tax_inclusive'   => $tax_inclusive,
			'prepaid'         => $prepaid,
			'rounding'        => HEIN_Money::is_zero( $rounding ) ? null : $rounding,
			'payable'         => $payable,
		];
	}

	private static function payment( WC_Order $order, array $settings ): array {
		$payment  = is_array( $settings['payment'] ?? null ) ? $settings['payment'] : [];
		$iban     = trim( (string) ( $payment['iban'] ?? '' ) );
		$bic      = trim( (string) ( $payment['bic'] ?? '' ) );
		$holder   = trim( (string) ( $payment['account_name'] ?? '' ) );
		$due_days = max( 0, (int) ( $payment['due_days'] ?? 0 ) );
		$terms    = trim( (string) ( $payment['terms_note'] ?? '' ) );
		$title    = trim( (string) $order->get_payment_method_title() );

		if ( '' === $terms ) {
			$terms = $due_days > 0
				? sprintf(
					/* translators: %d: number of days */
					__( 'Payment due within %d days.', 'harbor-eu-einvoicing-for-woocommerce' ),
					$due_days
				)
				: __( 'Payment due upon receipt.', 'harbor-eu-einvoicing-for-woocommerce' );
		}

		return [
			'means_code'   => HEIN_Codes::payment_means_code( (string) $order->get_payment_method(), '' !== $iban ),
			'means_name'   => '' !== $title ? $title : null,
			'reference'    => null,
			'iban'         => '' !== $iban ? $iban : null,
			'bic'          => '' !== $bic ? $bic : null,
			'account_name' => '' !== $holder ? $holder : null,
			'terms_note'   => $terms,
		];
	}

	private static function due_date( string $issue_date, int $due_days, string $payable ): ?string {
		if ( $due_days <= 0 || HEIN_Money::cents( $payable ) <= 0 ) {
			return null;
		}

		$due = strtotime( $issue_date . ' +' . $due_days . ' days' );

		return false !== $due ? gmdate( 'Y-m-d', $due ) : null;
	}

	/**
	 * DE-R-015 is fatal on a blank BT-10 for a DE -> DE invoice and most B2B orders have no PO
	 */
	private static function buyer_reference( WC_Order $order ): string {
		$po = trim( HEIN_Checkout_Fields::po( $order ) );

		if ( '' !== $po ) {
			return $po;
		}

		$number = trim( (string) $order->get_order_number() );

		return '' !== $number ? $number : (string) $order->get_id();
	}

	private static function category_ref( string $category, float $percent ): array {
		$exemption = HEIN_Codes::exemption( $category );

		return [
			'category'         => $category,
			'percent'          => 'O' === $category ? null : HEIN_Money::percent( $percent ),
			'exemption_code'   => $exemption['code'],
			'exemption_reason' => $exemption['reason'],
		];
	}

	private static function or_null( string $value ): ?string {
		$value = trim( $value );

		return '' !== $value ? $value : null;
	}

	private static function address_complete( array $address ): bool {
		foreach ( [ 'street', 'city', 'postcode', 'country' ] as $key ) {
			if ( '' === trim( (string) ( $address[ $key ] ?? '' ) ) ) {
				return false;
			}
		}

		return true;
	}
}
