<?php
/**
 * Abstract document base.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Invoices;

defined( 'ABSPATH' ) || exit;

/**
 * Base class for PDF document types (Invoice, Packing Slip, Credit Note).
 *
 * Provides order data accessors used by the block renderer to produce
 * Dompdf-compatible HTML.
 *
 * @since 1.0.0
 */
abstract class Document {

	protected \WC_Order $order;

	/** @var array<string, mixed> */
	public array $settings;

	/**
	 * Preview/builder render: never assign or consume sequential numbers,
	 * never write order/refund meta. Display a placeholder number instead.
	 *
	 * @since 1.0.0
	 */
	public bool $preview = false;

	/**
	 * @since 1.0.0
	 *
	 * @param \WC_Order $order    The order.
	 * @param array     $settings Module settings from get_default_settings().
	 */
	public function __construct( \WC_Order $order, array $settings ) {
		$this->order    = $order;
		$this->settings = $settings;
	}

	/**
	 * @since 1.0.0
	 * @return string e.g. 'invoice', 'packing-slip', 'credit-note'
	 */
	abstract public function get_type(): string;

	/**
	 * @since 1.0.0
	 * @return string Localised title, e.g. 'FACTUUR', 'PAKBON', 'CREDITNOTA'
	 */
	abstract public function get_title_label(): string;

	/**
	 * Whether this document type shows prices / VAT.
	 *
	 * @since 1.0.0
	 */
	public function shows_prices(): bool {
		return true;
	}

	/**
	 * @since 1.0.0
	 */
	public function get_order(): \WC_Order {
		return $this->order;
	}

	// -------------------------------------------------------------------------
	// Business / Shop Data (from Email Manager's kramar_business_details)
	// -------------------------------------------------------------------------

	/**
	 * @since 1.0.0
	 * @return array{company_name:string,statutory_name:string,company_address:string,statutory_seat:string,kvk_number:string,vat_number:string,legal_form:string,iban:string,email:string,phone:string}
	 */
	public function get_business_details(): array {
		$defaults = [
			'company_name'    => get_bloginfo( 'name' ),
			'statutory_name'  => '',
			'company_address' => '',
			'statutory_seat'  => '',
			'kvk_number'      => '',
			'vat_number'      => '',
			'legal_form'      => '',
			'iban'            => '',
			'email'           => get_option( 'admin_email' ),
			'phone'           => '',
		];

		$saved = get_option( 'kramar_business_details', [] );

		return is_array( $saved ) ? array_merge( $defaults, $saved ) : $defaults;
	}

	/**
	 * Resolve the brand logo URL.
	 *
	 * Single source of truth is the global Business Details logo
	 * (Kramar → Settings). Falls back to legacy locations so logos configured
	 * before this setting existed still appear.
	 *
	 * @since 1.0.0
	 * @return string Logo URL or empty string.
	 */
	public function get_logo_url(): string {
		// 1. Global brand logo (Settings → Business Details) — preferred.
		$biz = get_option( 'kramar_business_details', [] );
		if ( is_array( $biz ) && ! empty( $biz['logo_url'] ) ) {
			return (string) $biz['logo_url'];
		}

		// 2. Legacy: email "Template Styling" global logo.
		$email_settings = get_option( 'kramar_email_settings', [] );
		if ( is_array( $email_settings ) && ! empty( $email_settings['logo_url'] ) ) {
			return (string) $email_settings['logo_url'];
		}

		// 3. Legacy: logo set inside the email template's Header block.
		return self::logo_from_email_template();
	}

	/**
	 * Dig the logo out of the email template's Header block (back-compat).
	 *
	 * @since 1.0.0
	 */
	private static function logo_from_email_template(): string {
		$overrides = get_option( 'kramar_email_overrides', [] );
		if ( ! is_array( $overrides ) || empty( $overrides['_template'] ) ) {
			return '';
		}

		$template = $overrides['_template'];
		$groups   = [ $template['header_blocks'] ?? [], $template['footer_blocks'] ?? [], $template['blocks'] ?? [] ];

		foreach ( $groups as $blocks ) {
			if ( ! is_array( $blocks ) ) {
				continue;
			}
			foreach ( $blocks as $block ) {
				if ( ( $block['type'] ?? '' ) === 'header' && ! empty( $block['settings']['logo_url'] ) ) {
					return (string) $block['settings']['logo_url'];
				}
			}
		}

		return '';
	}

	// -------------------------------------------------------------------------
	// Billing / Shipping
	// -------------------------------------------------------------------------

	/**
	 * @since 1.0.0
	 * @return array{name:string,company:string,address_1:string,address_2:string,city:string,postcode:string,country:string,state:string,email:string,phone:string,vat_id:string}
	 */
	public function get_billing_data(): array {
		$o = $this->order;
		return [
			'name'      => trim( $o->get_billing_first_name() . ' ' . $o->get_billing_last_name() ),
			'company'   => $o->get_billing_company(),
			'address_1' => $o->get_billing_address_1(),
			'address_2' => $o->get_billing_address_2(),
			'city'      => $o->get_billing_city(),
			'postcode'  => $o->get_billing_postcode(),
			'country'   => $o->get_billing_country(),
			'state'     => $o->get_billing_state(),
			'email'     => $o->get_billing_email(),
			'phone'     => $o->get_billing_phone(),
			'vat_id'    => (string) $o->get_meta( '_billing_vat_id' ),
		];
	}

	/**
	 * @since 1.0.0
	 * @return array{name:string,company:string,address_1:string,address_2:string,city:string,postcode:string,country:string,state:string,phone:string}
	 */
	public function get_shipping_data(): array {
		$o = $this->order;
		return [
			'name'      => trim( $o->get_shipping_first_name() . ' ' . $o->get_shipping_last_name() ),
			'company'   => $o->get_shipping_company(),
			'address_1' => $o->get_shipping_address_1(),
			'address_2' => $o->get_shipping_address_2(),
			'city'      => $o->get_shipping_city(),
			'postcode'  => $o->get_shipping_postcode(),
			'country'   => $o->get_shipping_country(),
			'state'     => $o->get_shipping_state(),
			'phone'     => $o->get_shipping_phone(),
		];
	}

	/**
	 * Whether shipping address differs from billing.
	 *
	 * @since 1.0.0
	 */
	public function has_different_shipping(): bool {
		$b = $this->get_billing_data();
		$s = $this->get_shipping_data();

		return $s['address_1'] !== '' && (
			$s['address_1'] !== $b['address_1'] ||
			$s['city'] !== $b['city'] ||
			$s['postcode'] !== $b['postcode'] ||
			$s['country'] !== $b['country']
		);
	}

	// -------------------------------------------------------------------------
	// Document Data
	// -------------------------------------------------------------------------

	/**
	 * @since 1.0.0
	 * @return array{number:string,date:string,order_number:string,order_date:string,payment_method:string}
	 */
	public function get_document_data(): array {
		$o = $this->order;
		return [
			'number'         => (string) $o->get_meta( '_kramar_invoice_number' ),
			'date'           => (string) $o->get_meta( '_kramar_invoice_date' ),
			'order_number'   => (string) $o->get_order_number(),
			'order_date'     => wc_format_datetime( $o->get_date_created() ),
			'delivery_date'  => $this->get_delivery_date(),
			'payment_method' => $o->get_payment_method_title(),
		];
	}

	/**
	 * Date of supply (leverdatum) — required by EU VAT Directive Art. 226(7).
	 *
	 * Best available proxy in WooCommerce: completion date (goods shipped/
	 * fulfilled) → payment date (tax point for prepaid orders) → order date.
	 *
	 * @since 1.0.0
	 */
	protected function get_delivery_date(): string {
		$o    = $this->order;
		$date = $o->get_date_completed() ?: $o->get_date_paid() ?: $o->get_date_created();

		return $date ? wp_date( wc_date_format(), $date->getTimestamp() ) : '';
	}

	// -------------------------------------------------------------------------
	// Line Items
	// -------------------------------------------------------------------------

	/**
	 * @since 1.0.0
	 * @return list<array{name:string,sku:string,quantity:int,unit_price:float,total:float,tax_rate:float,tax_amount:float,image_url:string,meta:string}>
	 */
	public function get_line_items(): array {
		$items    = [];
		$tax_rate = $this->get_order_tax_rate();

		foreach ( $this->order->get_items() as $item_id => $item ) {
			$product = $item->get_product();
			$qty     = $item->get_quantity();
			$total   = (float) $item->get_total();
			$tax     = (float) $item->get_total_tax();

			// A line's own rate, from the rate it was actually taxed under. Falling
			// back to the order's first non-zero rate stamped a genuinely 0%-rated
			// line with an unrelated 21% in the VAT column — the totals block below
			// showed the correct 0% row, so the document contradicted itself. Only
			// visible on multi-rate orders: with one rate the fallback happened to
			// be right.
			$item_rate = $this->get_item_tax_rate( $item );

			if ( null !== $item_rate ) {
				$item_tax_rate = $item_rate;
			} else {
				$item_tax_rate = $tax > 0 && $total > 0
					? round( ( $tax / $total ) * 100, 1 )
					: $tax_rate;
			}

			$tax_amount    = $tax > 0 ? $tax : round( $total * $item_tax_rate / 100, 2 );
			$unit_price    = $qty > 0 ? $total / $qty : 0.0;

			$image_url = '';
			if ( $product instanceof \WC_Product ) {
				$img = wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' );
				$image_url = $img ? (string) $img : '';
			}

			$meta_html = '';
			$meta_data = $item->get_formatted_meta_data( '_', true );
			if ( ! empty( $meta_data ) ) {
				$parts = [];
				foreach ( $meta_data as $meta ) {
					$parts[] = wp_strip_all_tags( $meta->display_key . ': ' . $meta->display_value );
				}
				$meta_html = implode( ', ', $parts );
			}

			$items[] = [
				'name'       => $item->get_name(),
				'sku'        => $product instanceof \WC_Product ? $product->get_sku() : '',
				'quantity'   => $qty,
				'unit_price' => $unit_price,
				'total'      => $total,
				'tax_rate'   => $item_tax_rate,
				'tax_amount' => $tax_amount,
				'image_url'  => $image_url,
				'meta'       => $meta_html,
			];
		}

		return $items;
	}

	/**
	 * The rate a single line was taxed under, or null when it records none.
	 *
	 * @since 1.0.2
	 *
	 * @param \WC_Order_Item $item Line item.
	 * @return float|null Rate percentage, or null if the line has no rate.
	 */
	protected function get_item_tax_rate( \WC_Order_Item $item ): ?float {
		$taxes = $item->get_taxes();
		$per   = isset( $taxes['total'] ) && is_array( $taxes['total'] ) ? $taxes['total'] : [];

		// No tax entries means the line was taxed under no rate — a product whose
		// tax class has no rate row for this customer's country, say. That is 0%,
		// not "unknown": returning null sent it to the fallback below, which
		// stamped it with the order's dominant rate and computed a tax from it, so
		// a 40,00 line nobody was taxed on printed "21%" and "8,40" beside a
		// totals table that listed no rate it could belong to.
		if ( empty( $per ) ) {
			return 0.0;
		}

		// Several rates on one line have no single percentage to print.
		if ( count( $per ) > 1 ) {
			return null;
		}

		$pct = $this->rate_percent_for( (int) array_key_first( $per ) );

		return null !== $pct ? round( $pct, 1 ) : null;
	}

	/**
	 * The percentage a rate id was actually charged at on this order.
	 *
	 * The order's own tax line is the authority, not the live tax table.
	 * WooCommerce stores the percentage on the line precisely so a historical
	 * document survives the rate being edited or deleted afterwards — which is
	 * ordinary housekeeping on a live shop (a rate change, a country
	 * reorganisation, a move to another tax plugin). Reading the table instead
	 * printed "VAT 0% (over 46,31): 8,80" on every order whose rate had since
	 * been removed, and re-dated an old invoice to today's percentage when a
	 * rate was merely changed. Both are invalid invoices.
	 *
	 * @since 1.0.3
	 *
	 * @param int                     $rate_id Tax rate id.
	 * @param \WC_Abstract_Order|null $source  Refund to consult before the order.
	 * @return float|null Percentage, or null when nothing records one.
	 */
	protected function rate_percent_for( int $rate_id, ?\WC_Abstract_Order $source = null ): ?float {
		$sources = null !== $source ? [ $source, $this->order ] : [ $this->order ];

		foreach ( $sources as $src ) {
			foreach ( $src->get_items( 'tax' ) as $tax_item ) {
				if ( (int) $tax_item->get_rate_id() !== $rate_id ) {
					continue;
				}
				// Null on orders placed before WooCommerce 4.3, which did not
				// store it — those still have to fall through to the table.
				$stored = $tax_item->get_rate_percent();
				if ( null === $stored ) {
					continue;
				}

				// A stored 0% beside a line that actually took tax contradicts
				// itself, and importers that never call set_rate() leave exactly
				// that. Trusting it would print "VAT 0%: 4,59". A genuine
				// zero-rated line takes no tax, so this never rejects a real 0%.
				$charged = (float) $tax_item->get_tax_total() + (float) $tax_item->get_shipping_tax_total();
				if ( 0.0 === (float) $stored && abs( $charged ) >= 0.005 ) {
					continue;
				}

				return (float) $stored;
			}
		}

		$rate_obj = \WC_Tax::_get_tax_rate( $rate_id );

		return $rate_obj ? (float) $rate_obj['tax_rate'] : null;
	}

	/**
	 * Get the primary tax rate percentage from the order's tax line items.
	 *
	 * When prices include tax, per-item tax is 0 — derive from the tax rate object.
	 *
	 * @since 1.0.0
	 */
	private function get_order_tax_rate(): float {
		foreach ( $this->order->get_items( 'tax' ) as $tax_item ) {
			$pct = $this->rate_percent_for( (int) $tax_item->get_rate_id() );
			if ( null !== $pct && $pct > 0 ) {
				return $pct;
			}
		}
		return 0.0;
	}

	// -------------------------------------------------------------------------
	// Totals
	// -------------------------------------------------------------------------

	/**
	 * Recover the tax rates a line belongs to when it records none.
	 *
	 * Nothing to recover on an order: WooCommerce keeps a zero-value entry for a
	 * zero-rated line, so the rate is known. Credit notes override this — see
	 * Credit_Note::resolve_item_tax_rates().
	 *
	 * @since 1.0.2
	 *
	 * @param \WC_Order_Item $item The line with no tax entries.
	 * @return array<int|string, float> Tax amount keyed by rate id.
	 */
	protected function resolve_item_tax_rates( \WC_Order_Item $item ): array {
		return [];
	}

	/**
	 * Fee lines carried by this document, for disclosure in the totals block.
	 *
	 * Fees were on no part of the document. get_line_items() calls get_items()
	 * with no argument, which WooCommerce defaults to line items only, so a fee
	 * never reached the products table — while its net was already inside the
	 * VAT base and its amount inside the total. A 20,00 item with a 3,50 fee
	 * printed "Subtotal 16,53", "VAT 21% over 20,03", "Total 24,23": a charge
	 * the customer paid, appearing nowhere, and a base 3,50 above the subtotal
	 * directly beneath it with nothing to explain the gap.
	 *
	 * @since 1.0.2
	 *
	 * @return list<array{name:string,total:float}>
	 */
	public function get_fee_lines(): array {
		return $this->collect_fee_lines( $this->order );
	}

	/**
	 * Read fee lines off an order or a refund.
	 *
	 * @since 1.0.2
	 *
	 * @param \WC_Abstract_Order $source Order or refund carrying the fees.
	 * @return list<array{name:string,total:float}>
	 */
	protected function collect_fee_lines( \WC_Abstract_Order $source ): array {
		$fees = [];

		foreach ( $source->get_items( 'fee' ) as $fee ) {
			$total = (float) $fee->get_total();
			if ( abs( $total ) < 0.005 ) {
				continue;
			}

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

			$fees[] = [
				'name'  => '' !== $name ? $name : __( 'Fee', 'kramar' ),
				'total' => $total,
			];
		}

		return $fees;
	}

	/**
	 * Drop rate ids a line records but was not actually taxed under.
	 *
	 * Keying the taxable base on a rate being present rather than on it having
	 * produced tax is right for a genuine 0% rate — zero tax there is the
	 * correct outcome and the line still has a base to declare. It is wrong for
	 * a zero recorded against a rate that charges more than 0%: some shipping
	 * methods and import routines write a zero-valued entry for every rate on
	 * the order, whether or not it applied. Presence alone cannot tell the two
	 * apart, and treating them alike put untaxed shipping inside a 21% base —
	 * "Subtotal 20,66" beside "VAT 21% (over 28,61): 4,34", which is not a
	 * valid invoice under Art. 226(8)-(10).
	 *
	 * The rate's own percentage is the discriminator: at 0% a zero is expected,
	 * above 0% a zero means this line was not taxed at that rate.
	 *
	 * @since 1.0.3
	 *
	 * @param array<int|string, float> $taxed  Tax amount keyed by rate id.
	 * @param \WC_Abstract_Order       $source Order or refund carrying the line.
	 * @return array<int|string, float>
	 */
	private function drop_rates_that_took_no_tax( array $taxed, \WC_Abstract_Order $source ): array {
		foreach ( $taxed as $rate_id => $amount ) {
			if ( abs( (float) $amount ) >= 0.005 ) {
				continue;
			}

			$percent = $this->rate_percent_for( (int) $rate_id, $source );

			// Unknown percentage: keep it. Dropping a rate we cannot identify
			// would silently move a real base out of the breakdown.
			if ( null !== $percent && abs( $percent ) >= 0.005 ) {
				unset( $taxed[ $rate_id ] );
			}
		}

		return $taxed;
	}

	/**
	 * Get order totals grouped by VAT rate.
	 *
	 * @since 1.0.0
	 * @return list<array{rate:float,label:string,subtotal:float,tax:float}>
	 */
	public function get_totals_by_vat_rate(): array {
		return $this->compute_vat_rates( $this->order, $this->get_grand_totals() );
	}

	/**
	 * Group an order's or refund's totals by VAT rate.
	 *
	 * Shared by invoices and credit notes. Credit notes used to derive the base
	 * the other way — tax divided by rate — which is exactly what the comment
	 * below explains does not work, and it put "Subtotal -24,79" beside "VAT 21%
	 * over -24,81" on the same document. A refund's item totals and tax lines are
	 * already stored negative, so the same arithmetic serves both with no
	 * special-casing.
	 *
	 * @since 1.0.2
	 *
	 * @param \WC_Abstract_Order   $source Order or refund carrying the items.
	 * @param array<string, float> $totals The totals this document prints.
	 * @return list<array{rate:float,label:string,subtotal:float,tax:float}>
	 */
	protected function compute_vat_rates( \WC_Abstract_Order $source, array $totals ): array {
		// The taxable base per rate is summed from the lines actually taxed at
		// that rate, NOT reverse-calculated as tax ÷ rate. WooCommerce rounds
		// tax per line, so dividing the rounded total back out lands a cent or
		// two away from the invoice's own subtotal — putting two figures that
		// must agree on the same document ("Subtotal 90,05" beside "VAT 21%
		// over 90,00"). Summing the lines makes the base the same number the
		// subtotal is built from.
		$base_by_rate_id = [];

		// Net carried by lines that belong to no tax rate at all. It is part of
		// the printed subtotal/shipping, so the reconciliation below has to know
		// about it — otherwise it treats the gap as rounding drift and scales it
		// onto a taxed rate's base. Untaxed shipping is the everyday case: a
		// €3,00 shipping line with no tax turned "VAT 21% over €20,00: €4,20"
		// into "over €23,00: €4,20", which does not multiply out.
		$untaxed_net = 0.0;

		foreach ( $source->get_items( [ 'line_item', 'shipping', 'fee' ] ) as $item ) {
			$taxes = $item->get_taxes();
			$per   = isset( $taxes['total'] ) && is_array( $taxes['total'] ) ? $taxes['total'] : [];

			// Rate ids this line was taxed under. Keyed on the rate being PRESENT,
			// not on it having produced tax: a line at a genuine 0% rate has a
			// real taxable base and zero tax, and testing the amount dropped it
			// from every base — so a 0% row printed "over €0,00" while its net
			// was silently scaled onto another rate's base by the reconciliation
			// below, leaving a row like "VAT 21% (over €130,00): €21,00" that
			// does not multiply out.
			$taxed = [];
			foreach ( $per as $rate_id => $amount ) {
				$taxed[ $rate_id ] = (float) $amount;
			}

			if ( empty( $taxed ) ) {
				$taxed = $this->resolve_item_tax_rates( $item );
			}

			$taxed = $this->drop_rates_that_took_no_tax( $taxed, $source );

			if ( empty( $taxed ) ) {
				// Genuinely untaxed line — belongs to no rate's base, but still
				// sits inside the printed net.
				$untaxed_net += (float) $item->get_total();
				continue;
			}

			$line_net = (float) $item->get_total();

			if ( 1 === count( $taxed ) ) {
				$rate_id                     = array_key_first( $taxed );
				$base_by_rate_id[ $rate_id ] = ( $base_by_rate_id[ $rate_id ] ?? 0.0 ) + $line_net;
				continue;
			}

			// A single line taxed under several rates is rare, but split its net
			// across them in proportion to the tax each rate took. When none of
			// them took any tax (every applicable rate is 0%), proportion is
			// undefined — split evenly rather than dividing by zero and losing
			// the base altogether.
			$tax_sum = array_sum( $taxed );
			foreach ( $taxed as $rate_id => $amount ) {
				$share                       = 0.0 !== $tax_sum ? ( $amount / $tax_sum ) : ( 1 / count( $taxed ) );
				$base_by_rate_id[ $rate_id ] = ( $base_by_rate_id[ $rate_id ] ?? 0.0 ) + ( $line_net * $share );
			}
		}

		$rates = [];

		foreach ( $source->get_items( 'tax' ) as $tax_item ) {
			$rate_id    = $tax_item->get_rate_id();
			$rate_pct   = $this->rate_percent_for( (int) $rate_id, $source ) ?? 0.0;
			$tax_amount = (float) $tax_item->get_tax_total() + (float) $tax_item->get_shipping_tax_total();

			$rates[] = [
				'rate'      => $rate_pct,
				'label'     => $tax_item->get_label(),
				'subtotal'  => round( $base_by_rate_id[ $rate_id ] ?? ( $rate_pct > 0 ? $tax_amount / ( $rate_pct / 100 ) : 0.0 ), 2 ),
				'tax'       => $tax_amount,
				'_rate_id'  => $rate_id,
			];
		}

		// A rate that carries a base but produced no tax may have no tax line at
		// all to be found above. Orders get one per applicable rate including
		// zero-rated ones, but a refund only records the rates that actually took
		// tax — so crediting a mixed 21% and 0% order dropped the 0% row entirely
		// and left the remaining bases short of the credited subtotal by the whole
		// zero-rated amount. Give every base a row.
		foreach ( $base_by_rate_id as $rate_id => $base ) {
			foreach ( $rates as $existing ) {
				if ( (int) $existing['_rate_id'] === (int) $rate_id ) {
					continue 2;
				}
			}

			$rate_obj = \WC_Tax::_get_tax_rate( $rate_id );

			$rates[] = [
				'rate'     => $this->rate_percent_for( (int) $rate_id, $source ) ?? 0.0,
				'label'    => $rate_obj && '' !== (string) $rate_obj['tax_rate_name']
					? (string) $rate_obj['tax_rate_name']
					: __( 'VAT', 'kramar' ),
				'subtotal' => round( $base, 2 ),
				'tax'      => 0.0,
				'_rate_id' => $rate_id,
			];
		}

		// Reconcile against the figures this document actually prints. The bases
		// are summed from line values carried at four decimals, while the
		// Subtotal line prints WooCommerce's own get_subtotal() — so the two can
		// still land a cent apart, which on an invoice reads as an error. Scale
		// the bases onto the printed net and give any remaining cent to the
		// largest rate, so "VAT 21% over X" always adds up to what is above it.
		//
		// Only the taxed portion of the net is up for reconciliation. Untaxed
		// lines are excluded, and the correction is capped at a rounding-sized
		// amount: a larger gap is not drift but a real structural difference,
		// and quietly absorbing it would print a VAT row whose base and tax do
		// not multiply out. Each row multiplying out matters more on a legal
		// document than the bases summing to the line above them.
		//
		// "Multiplies out" here means to within a cent, not exactly. A base and a
		// tax are each rounded to two decimals from an unrounded net, and both
		// cannot hold at once: a 10,00 gross line at 21% is a base of 8,26 and a
		// tax of 1,74, which sum to the 10,00 actually charged, while 8,26 x 21%
		// is 1,73. Printing 1,73 would make the document total 9,99 against 10,00
		// taken from the customer. Reconciling with the money wins.
		$printed_net = round(
			(float) ( $totals['subtotal'] ?? 0 ) + (float) ( $totals['shipping'] ?? 0 )
				- (float) ( $totals['discount'] ?? 0 ) - $untaxed_net,
			2
		);
		$base_sum    = round( array_sum( array_column( $rates, 'subtotal' ) ), 2 );
		$tolerance   = 0.01 * max( 1, count( $rates ) ) + 0.005;

		if (
			count( $rates ) > 0
			&& abs( $base_sum - $printed_net ) >= 0.005
			&& abs( $base_sum - $printed_net ) <= $tolerance
			&& abs( $base_sum ) > 0
		) {
			$scale = $printed_net / $base_sum;
			foreach ( $rates as $i => $rate ) {
				$rates[ $i ]['subtotal'] = round( $rate['subtotal'] * $scale, 2 );
			}

			// Rounding each share can still leave a cent unaccounted for.
			$drift = round( $printed_net - array_sum( array_column( $rates, 'subtotal' ) ), 2 );
			if ( abs( $drift ) >= 0.005 ) {
				$largest = 0;
				foreach ( $rates as $i => $rate ) {
					if ( abs( $rate['subtotal'] ) > abs( $rates[ $largest ]['subtotal'] ) ) {
						$largest = $i;
					}
				}
				$rates[ $largest ]['subtotal'] = round( $rates[ $largest ]['subtotal'] + $drift, 2 );
			}
		}

		foreach ( $rates as $i => $rate ) {
			unset( $rates[ $i ]['_rate_id'] );
		}

		return array_values( $rates );
	}

	/**
	 * @since 1.0.0
	 * @return array{subtotal:float,shipping:float,discount:float,tax:float,total:float}
	 */
	public function get_grand_totals(): array {
		$o = $this->order;
		return [
			'subtotal' => (float) $o->get_subtotal(),
			'shipping' => (float) $o->get_shipping_total(),
			'discount' => (float) $o->get_discount_total(),
			'tax'      => (float) $o->get_total_tax(),
			'total'    => (float) $o->get_total(),
		];
	}

	// -------------------------------------------------------------------------
	// Reverse Charge Detection
	// -------------------------------------------------------------------------

	/**
	 * Whether this order qualifies for reverse charge (B2B intra-EU).
	 *
	 * @since 1.0.0
	 */
	public function is_reverse_charge(): bool {
		$vat_id = (string) $this->order->get_meta( '_billing_vat_id' );
		if ( '' === $vat_id ) {
			return false;
		}

		// The VAT ID module records its decision as _vat_reverse_charge_applied
		// (modules/checkout/class-vat-id.php). Nothing in the plugin has ever
		// written _vat_exempt to order meta, so reading only that key made this
		// permanently false and every genuine intra-EU B2B invoice shipped
		// without the art. 138/196 notice the buyer needs for their own VAT
		// return. _vat_exempt is still honoured for orders written by hand or by
		// third-party code.
		if ( 'yes' === (string) $this->order->get_meta( '_vat_reverse_charge_applied' ) ) {
			return true;
		}

		$exempt = $this->order->get_meta( '_vat_exempt' );
		return 'yes' === $exempt || true === $exempt;
	}

	/**
	 * Whether the order actually carries no VAT.
	 *
	 * Reverse charge means the VAT is accounted for by the recipient, so a
	 * reverse-charged invoice must show zero VAT. If an order is flagged for
	 * reverse charge but WooCommerce still calculated tax on it, hiding the VAT
	 * breakdown would leave the total unexplained — subtotal plus nothing shown
	 * would not equal the amount billed.
	 *
	 * @since 1.0.2
	 */
	public function has_zero_tax(): bool {
		return abs( (float) $this->order->get_total_tax() ) < 0.005;
	}

	// -------------------------------------------------------------------------
	// Formatted Address Helpers
	// -------------------------------------------------------------------------

	/**
	 * Format an address array into lines.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data Address data array.
	 * @return string HTML with <br> line breaks.
	 */
	public function format_address( array $data ): string {
		$lines = [];

		if ( ! empty( $data['company'] ) ) {
			$lines[] = esc_html( $data['company'] );
		}
		if ( ! empty( $data['name'] ) ) {
			$lines[] = esc_html( $data['name'] );
		}
		if ( ! empty( $data['address_1'] ) ) {
			$lines[] = esc_html( $data['address_1'] );
		}
		if ( ! empty( $data['address_2'] ) ) {
			$lines[] = esc_html( $data['address_2'] );
		}

		$city_line = '';
		if ( ! empty( $data['postcode'] ) ) {
			$city_line .= esc_html( $data['postcode'] ) . ' ';
		}
		if ( ! empty( $data['city'] ) ) {
			$city_line .= esc_html( $data['city'] );
		}
		if ( '' !== $city_line ) {
			$lines[] = $city_line;
		}

		if ( ! empty( $data['country'] ) ) {
			$countries = WC()->countries->get_countries();
			$lines[]   = esc_html( $countries[ $data['country'] ] ?? $data['country'] );
		}

		return implode( '<br>', $lines );
	}
}
