<?php

declare( strict_types=1 );

namespace Brightfern\WooCommerce;

use Brightfern\Invoice\Input\OrderCharge;
use Brightfern\Invoice\Input\OrderData;
use Brightfern\Invoice\Input\OrderLine;
use Brightfern\Settings;
use WC_Order;
use WC_Order_Item_Fee;
use WC_Order_Item_Product;
use WC_Order_Item_Shipping;
use WC_Order_Refund;

/**
 * The ONLY bridge between WooCommerce and the pure invoice model: it reads a
 * WC_Order (always via the CRUD API, so HPOS-safe) and produces a plain
 * {@see OrderData}. Keeping this adapter thin and isolated is what lets the
 * mapper/serializer stay WordPress-free and fully unit-tested.
 *
 * Buyer Peppol identity (VAT, electronic address) is read from order meta with
 * filterable keys, because how a store captures B2B identifiers varies (EU VAT
 * plugins, custom checkout fields, etc.). Where the electronic address is
 * absent, we derive a VAT-based EAS as a best-effort fallback — the pre-flight
 * SMP lookup then confirms reachability.
 */
final class WooOrderAdapter {

	/** Minimal EAS (Electronic Address Scheme) fallback by VAT country. */
	private const VAT_EAS = array(
		'BE' => '9925', 'FR' => '9957', 'DE' => '9930', 'NL' => '9944',
		'IT' => '9906', 'ES' => '9920', 'LU' => '9938', 'AT' => '9914',
	);

	public function toOrderData( WC_Order $order ): OrderData {
		$o = new OrderData();

		$o->number    = $order->get_order_number();
		$o->currency  = $order->get_currency();
		$o->issueDate = gmdate( 'Y-m-d', $this->issueTimestamp( $order ) );
		$o->dueDate   = gmdate( 'Y-m-d', $this->issueTimestamp( $order ) + ( (int) Settings::get( 'due_days', 30 ) * DAY_IN_SECONDS ) );

		$o->orderReference = $order->get_order_number();
		// XRechnung's BT-10 must be a real routing reference (often a Leitweg-ID) —
		// falling back to the WooCommerce order number would silently satisfy our
		// own "is it present" gate with a value the receiving portal will reject,
		// so that fallback only applies to Peppol, where BT-10 is optional and any
		// non-empty reference is genuinely useful.
		$explicitBuyerReference = (string) $this->meta( $order, 'brightfern_buyer_reference', '_billing_po_number' );
		$o->buyerReference      = '' !== $explicitBuyerReference
			? $explicitBuyerReference
			: ( 'xrechnung' === Settings::format() ? '' : $order->get_order_number() );
		$o->note           = (string) $order->get_customer_note();

		// Buyer identity.
		$company        = $order->get_billing_company();
		$o->buyerName   = '' !== $company ? $company : trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
		$o->buyerVat    = (string) $this->meta( $order, 'brightfern_buyer_vat', '_billing_vat', '_vat_number', '_billing_eu_vat_number' );
		$o->buyerEmail  = $order->get_billing_email();
		$o->buyerPhone  = $order->get_billing_phone();

		$o->buyerLine1        = $order->get_billing_address_1();
		$o->buyerLine2        = $order->get_billing_address_2();
		$o->buyerCity         = $order->get_billing_city();
		$o->buyerPostalZone   = $order->get_billing_postcode();
		$o->buyerCountrySubentity = $order->get_billing_state();
		$o->buyerCountryCode  = strtoupper( $order->get_billing_country() );
		$o->shipToCountryCode = strtoupper( $order->get_shipping_country() ?: $order->get_billing_country() );

		[ $scheme, $id ]      = $this->buyerEndpoint( $order, $o->buyerVat, $o->buyerCountryCode );
		$o->buyerEndpointScheme = $scheme;
		$o->buyerEndpointId     = $id;

		// Lines.
		foreach ( $order->get_items() as $item ) {
			if ( $item instanceof WC_Order_Item_Product ) {
				$o->lines[] = $this->productLine( $item );
			}
		}

		// Shipping → charge (positive total) or allowance (negative total, e.g. a
		// shipping rebate applied directly to the shipping line — WooCommerce
		// imposes no lower bound on WC_Order_Item_Shipping::get_total()). Mirrors
		// the fee loop below: OrderCharge::amount is always positive, direction
		// is the isCharge flag, so the sign must be resolved HERE, not passed
		// through — a signed $net used to reach OrderCharge::shipping(), which
		// hardcodes isCharge=true, turning a rebate into a same-magnitude charge
		// and silently inflating the invoice by double the rebate.
		foreach ( $order->get_items( 'shipping' ) as $shipping ) {
			if ( $shipping instanceof WC_Order_Item_Shipping ) {
				$net = (float) $shipping->get_total();
				if ( abs( $net ) > 0.0 ) {
					$o->charges[] = new OrderCharge(
						$net > 0.0,
						abs( $net ),
						$this->effectiveRate( $net, (float) $shipping->get_total_tax() ),
						'Shipping',
						'SAA'
					);
				}
			}
		}

		// Fees → charge (positive) or allowance (negative).
		foreach ( $order->get_items( 'fee' ) as $fee ) {
			if ( $fee instanceof WC_Order_Item_Fee ) {
				$net = (float) $fee->get_total();
				if ( abs( $net ) <= 0.0 ) {
					continue;
				}
				$o->charges[] = new OrderCharge(
					$net > 0.0,
					abs( $net ),
					$this->effectiveRate( $net, (float) $fee->get_total_tax() ),
					$fee->get_name()
				);
			}
		}

		// Domestic reverse charge and prepayment are opt-in via settings/meta.
		$o->reverseCharge = (bool) $this->meta( $order, 'brightfern_reverse_charge', '' );
		if ( (bool) Settings::get( 'mark_prepaid', false ) && $order->is_paid() ) {
			$o->prepaidAmount = (float) $order->get_total();
		}

		$this->ensureAtLeastOneLine( $o );

		return $o;
	}

	/**
	 * EN 16931 (BG-25, enforced outright by the CII binding Factur-X uses) requires
	 * at least one invoice line. An order or refund composed entirely of shipping
	 * and/or fee items — no WC_Order_Item_Product — has real billable value but
	 * zero product lines, which used to reach the serializer as an empty
	 * Invoice::$lines: schema-invalid, and FacturXBuilder::build() threw on it,
	 * leaving the order permanently on STATUS_FAILED (every resend() hits the
	 * identical exception).
	 *
	 * Fix: when there are no lines but there ARE charges, represent each charge
	 * as its own invoice line instead of a document-level allowance/charge —
	 * NOT both, which would double the amount. Only reachable at the very end of
	 * toOrderData()/toCreditNoteData() once every source of lines/charges has
	 * already been read, so nothing here changes behaviour for the ordinary
	 * (has-product-lines) case.
	 */
	private function ensureAtLeastOneLine( OrderData $o ): void {
		if ( array() !== $o->lines || array() === $o->charges ) {
			return;
		}

		foreach ( $o->charges as $charge ) {
			$amount     = $charge->isCharge ? $charge->amount : -$charge->amount;
			$o->lines[] = new OrderLine(
				(string) ( count( $o->lines ) + 1 ),
				'' !== $charge->reasonText ? $charge->reasonText : __( 'Charge', 'brightfern-e-invoicing-for-woocommerce' ),
				1.0,
				$amount,
				$amount,
				$charge->taxRatePercent
			);
		}
		$o->charges = array();
	}

	/**
	 * Builds a credit note {@see OrderData} from a WooCommerce refund. Reuses the
	 * parent order's buyer identity (the credit note is issued to the same
	 * party as the original invoice) and overrides the document-specific
	 * fields with the refund's own lines and amounts.
	 *
	 * WooCommerce stores refunded line/shipping/fee totals as NEGATIVE amounts;
	 * a credit note document uses positive amounts (the document type itself
	 * conveys the crediting direction), so every amount is taken as absolute.
	 */
	public function toCreditNoteData( WC_Order_Refund $refund, WC_Order $order, string $precedingInvoiceNumber ): OrderData {
		$o = $this->toOrderData( $order );

		$o->number                = $this->creditNoteNumber( $order, $refund );
		$o->issueDate              = gmdate( 'Y-m-d', $this->refundTimestamp( $refund ) );
		$o->dueDate                = '';
		$o->note                   = (string) $refund->get_reason();
		$o->prepaidAmount          = 0.0;
		$o->isCreditNote           = true;
		$o->precedingInvoiceNumber = $precedingInvoiceNumber;

		$o->lines   = array();
		$o->charges = array();

		// Accumulated in parallel with the loops below (WooCommerce's own
		// resolved net+tax per item, not re-derived from the mapped model) so it
		// can be reconciled against $refund->get_amount() afterwards — see the
		// comment above ensureRefundAmountReconciles().
		$grossFromItems = 0.0;

		foreach ( $refund->get_items() as $item ) {
			if ( $item instanceof WC_Order_Item_Product ) {
				$o->lines[]      = $this->refundProductLine( $item );
				$grossFromItems += abs( (float) $item->get_total() ) + abs( (float) $item->get_total_tax() );
			}
		}

		foreach ( $refund->get_items( 'shipping' ) as $shipping ) {
			if ( $shipping instanceof WC_Order_Item_Shipping ) {
				$net = abs( (float) $shipping->get_total() );
				if ( $net > 0.0 ) {
					$o->charges[]    = OrderCharge::shipping( $net, $this->effectiveRate( $net, abs( (float) $shipping->get_total_tax() ) ) );
					$grossFromItems += $net + abs( (float) $shipping->get_total_tax() );
				}
			}
		}

		foreach ( $refund->get_items( 'fee' ) as $fee ) {
			if ( $fee instanceof WC_Order_Item_Fee ) {
				$net = (float) $fee->get_total();
				if ( abs( $net ) <= 0.0 ) {
					continue;
				}
				// A refunded charge appears negative; crediting it back keeps its
				// original nature (charge vs allowance), so the sign is inverted.
				$o->charges[]    = new OrderCharge(
					$net < 0.0,
					abs( $net ),
					$this->effectiveRate( $net, (float) $fee->get_total_tax() ),
					$fee->get_name()
				);
				$grossFromItems += abs( $net ) + abs( (float) $fee->get_total_tax() );
			}
		}

		$this->ensureAtLeastOneLine( $o );
		$this->ensureRefundAmountReconciles( $o, $refund, $grossFromItems );

		return $o;
	}

	/**
	 * WooCommerce lets a refund's `amount` (the money actually returned to the
	 * customer, wc_create_refund()'s own parameter) diverge from the sum of its
	 * `line_items` — a merchant can refund specific product lines and separately
	 * type a larger total to also cover return shipping or as a goodwill gesture;
	 * WooCommerce stores both independently with no equality enforcement, in the
	 * admin UI or via the REST API. Previously nothing here compared the two, so
	 * the credit note silently understated what was actually refunded — internally
	 * self-consistent (its own totals reconcile), so no validator layer catches
	 * a mismatch it has no way to see.
	 *
	 * Fix: if the item-derived gross falls short of the refund's own recorded
	 * amount, add one explicit adjustment line for the gap, at 0% (the "extra"
	 * portion has no item to derive a real VAT rate from, so asserting one would
	 * be fabricating a rate rather than reporting a fact). Only ever tops up a
	 * shortfall — WooCommerce does not allow per-item refund amounts to exceed
	 * the refund's own total, so a shortfall is the only direction this can go.
	 *
	 * This is also where a flat "amount only" refund with no line/shipping/fee
	 * items at all ends up: $grossFromItems is 0, so the entire amount becomes
	 * one adjustment line — still the mandatory BG-25 content, just without the
	 * per-rate VAT breakdown that only itemised data can provide.
	 */
	private function ensureRefundAmountReconciles( OrderData $o, WC_Order_Refund $refund, float $grossFromItems ): void {
		$actual = abs( (float) $refund->get_amount() );
		$gap    = round( $actual - $grossFromItems, 2 );
		if ( $gap <= 0.005 ) {
			return;
		}

		// Not `array() === $o->charges`: ensureAtLeastOneLine() (called just
		// before this) already empties $o->charges whenever it converts them to
		// lines, so that check can no longer tell "no charges ever existed"
		// apart from "charges existed and became lines". $grossFromItems is the
		// reliable signal: it is exactly 0 only when no product/shipping/fee
		// item contributed anything at all.
		$label = $grossFromItems <= 0.005
			? __( 'Refund', 'brightfern-e-invoicing-for-woocommerce' )
			: __( 'Refund adjustment', 'brightfern-e-invoicing-for-woocommerce' );

		$o->lines[] = new OrderLine(
			(string) ( count( $o->lines ) + 1 ),
			$label,
			1.0,
			$gap,
			$gap,
			0.0
		);
	}

	private function refundProductLine( WC_Order_Item_Product $item ): OrderLine {
		$product = $item->get_product();
		$sku     = $product ? (string) $product->get_sku() : '';

		return new OrderLine(
			(string) $item->get_id(),
			$item->get_name(),
			abs( (float) $item->get_quantity() ),
			abs( (float) $item->get_subtotal() ),
			abs( (float) $item->get_total() ),
			$this->lineRate( $item ),
			$sku
		);
	}

	private function creditNoteNumber( WC_Order $order, WC_Order_Refund $refund ): string {
		return $order->get_order_number() . '-CN-' . $refund->get_id();
	}

	private function refundTimestamp( WC_Order_Refund $refund ): int {
		$created = $refund->get_date_created();
		return $created ? $created->getTimestamp() : time();
	}

	private function productLine( WC_Order_Item_Product $item ): OrderLine {
		$product = $item->get_product();
		$sku     = $product ? (string) $product->get_sku() : '';

		return new OrderLine(
			(string) $item->get_id(),
			$item->get_name(),
			(float) $item->get_quantity(),
			(float) $item->get_subtotal(),       // Net before discount.
			(float) $item->get_total(),          // Net after discount → BT-131.
			$this->lineRate( $item ),
			$sku
		);
	}

	/**
	 * Resolve a line's VAT rate. Prefer the configured tax rate (exact) and fall
	 * back to deriving it from the tax/net amounts. Deriving alone is risky
	 * (rounding can yield 20.99 instead of 21.00), so we snap to the real rate
	 * when WooCommerce can tell us.
	 */
	private function lineRate( WC_Order_Item_Product $item ): float {
		$taxes = $item->get_taxes();
		$totals = is_array( $taxes['total'] ?? null ) ? $taxes['total'] : array();

		foreach ( $totals as $rateId => $amount ) {
			if ( '' === (string) $amount ) {
				continue;
			}
			if ( class_exists( \WC_Tax::class ) ) {
				$percent = \WC_Tax::get_rate_percent( (int) $rateId ); // e.g. "21.0000%".
				$value   = (float) rtrim( (string) $percent, '%' );
				if ( $value > 0.0 ) {
					return round( $value, 2 );
				}
			}
		}

		return $this->effectiveRate( (float) $item->get_total(), (float) $item->get_total_tax() );
	}

	private function effectiveRate( float $net, float $tax ): float {
		if ( abs( $net ) <= 0.0 || abs( $tax ) <= 0.0 ) {
			return 0.0;
		}
		return round( $tax / $net * 100, 2 );
	}

	/**
	 * @return array{0:string,1:string} [scheme, id]
	 */
	private function buyerEndpoint( WC_Order $order, string $vat, string $country ): array {
		$scheme = (string) $this->meta( $order, 'brightfern_buyer_endpoint_scheme', '' );
		$id     = (string) $this->meta( $order, 'brightfern_buyer_endpoint_id', '' );
		if ( '' !== $scheme && '' !== $id ) {
			return array( $scheme, $id );
		}

		// Fallback: derive a VAT-based EAS. Merchants should confirm the buyer's
		// real Peppol address; this just gives the SMP lookup something to test.
		$vatClean = strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', $vat ) ?? '' );
		$vatCountry = substr( $vatClean, 0, 2 );
		$eas        = self::VAT_EAS[ $vatCountry ] ?? ( self::VAT_EAS[ $country ] ?? '' );
		if ( '' !== $eas && '' !== $vatClean ) {
			return array( $eas, $vatClean );
		}

		return array( '', '' );
	}

	/** Read the first non-empty meta value among the given keys. */
	private function meta( WC_Order $order, string ...$keys ): string {
		foreach ( $keys as $key ) {
			if ( '' === $key ) {
				continue;
			}
			$value = $order->get_meta( $key );
			if ( is_scalar( $value ) && '' !== (string) $value ) {
				return (string) $value;
			}
		}
		return '';
	}

	private function issueTimestamp( WC_Order $order ): int {
		$paid    = $order->get_date_paid();
		$created = $order->get_date_created();
		$date    = $paid ?: $created;
		return $date ? $date->getTimestamp() : time();
	}
}
