<?php

declare( strict_types=1 );

namespace Brightfern\Inbound;

use WC_Order;

/**
 * Best-effort matching of an inbound document to an existing WooCommerce order
 * (and thereby a customer). Inbound e-invoices are typically supplier bills, so
 * a hard match is not always possible — this returns a candidate order id or 0
 * and the inbox flags anything unmatched for manual handling.
 *
 * Matching strategy, most-specific first:
 *   1. The document's BuyerReference (BT-10) or OrderReference (BT-13) equals a
 *      WooCommerce order number/id, additionally sanity-checked against the
 *      order's own total when matched by bare numeric id — see
 *      {@see plausiblyMatchesOrder()} for why.
 *   2. The supplier VAT matches a previously-stored supplier↔order mapping
 *      (Phase-2 hook; not built in v1).
 *
 * All WooCommerce access goes through the CRUD API (wc_get_order / wc_get_orders)
 * so it is HPOS-safe.
 *
 * A match here is advisory, never authoritative: it labels an inbox row for
 * human review (InboundStore::STATUS_MATCHED) and never mutates the order
 * itself, transfers funds, or triggers any automated action — the merchant
 * always makes the actual accounting decision.
 */
final class OrderMatcher {

	public function match( InboundSummary $summary ): int {
		foreach ( array( $summary->orderReference, $summary->buyerReference ) as $ref ) {
			$ref = trim( $ref );
			if ( '' === $ref ) {
				continue;
			}

			$orderId = $this->resolveOrderId( $ref, $summary );
			if ( $orderId > 0 ) {
				return $orderId;
			}
		}

		return 0;
	}

	/**
	 * Resolve a reference string to a WooCommerce order id, if one exists AND
	 * plausibly matches — checked once here regardless of WHICH strategy below
	 * found the candidate, so a rejection on one path can't be silently
	 * recovered by falling through to the other for the same reference/order.
	 */
	private function resolveOrderId( string $reference, InboundSummary $summary ): int {
		if ( ! function_exists( 'wc_get_order' ) ) {
			return 0;
		}

		$order = null;

		// Numeric reference → try a direct order id lookup. This is the weakest
		// signal here: WooCommerce order ids are small, sequential, guessable
		// integers, and the reference comes from the inbound document itself —
		// sender-controlled content, not something this plugin has verified.
		// A forged document whose BuyerReference happens to equal a real order
		// id used to auto-label as "matched" against that order with nothing
		// else checked, lending it false legitimacy in the merchant's inbox — a
		// classic fake-invoice vector. plausiblyMatchesOrder() below closes the
		// specific case an attacker controls for free (a wildly wrong amount);
		// it does not, and cannot, prove the document is genuine — a forger who
		// also guesses a plausible amount still gets through, same as they
		// would guessing any other field a legitimate sender might get right.
		// That residual risk is exactly why matches stay reviewable, never
		// auto-actioned (see InboundStore — a match only labels an inbox row,
		// it never touches the order itself).
		if ( ctype_digit( $reference ) ) {
			$order = wc_get_order( (int) $reference );
		}

		// Otherwise (or if the numeric lookup found nothing), treat it as an
		// order number (plugins can customise these) — an equally
		// sender-supplied reference, so it gets the same check below.
		if ( ! $order && function_exists( 'wc_get_orders' ) ) {
			$orders = wc_get_orders(
				array(
					'limit'      => 1,
					'return'     => 'ids',
					'meta_query' => array(
						array(
							'key'   => '_order_number',
							'value' => $reference,
						),
					),
				)
			);
			if ( ! empty( $orders ) ) {
				$order = wc_get_order( (int) $orders[0] );
			}
		}

		if ( ! $order ) {
			return 0;
		}

		// wc_get_order() can also resolve to a WC_Order_Refund (a sibling of
		// WC_Order, not a subclass — the two share no common typed ancestor
		// with get_total()), so the plausibility check only applies when it
		// actually is an order; a refund id match falls back to the
		// reference-only trust the code always had for that rare case.
		if ( $order instanceof WC_Order && ! $this->plausiblyMatchesOrder( $order, $summary ) ) {
			return 0;
		}

		return (int) $order->get_id();
	}

	/**
	 * A loose, deliberately generous sanity check against the order's own
	 * recorded total — NOT proof the document is genuine, just a guard against
	 * the specific thing a forger controls for free: a reference number that
	 * happens to equal a real order id, paired with an amount nowhere near what
	 * that order was actually for. An order of magnitude of slack in either
	 * direction accommodates partial/currency/rounding differences between a
	 * genuinely related document and the order total; skipped entirely (i.e.
	 * trusts the reference alone, as before) when either figure is unusable,
	 * since there is nothing to compare.
	 */
	private function plausiblyMatchesOrder( WC_Order $order, InboundSummary $summary ): bool {
		if ( $summary->payableAmount <= 0.0 ) {
			return true;
		}
		$orderTotal = (float) $order->get_total();
		if ( $orderTotal <= 0.0 ) {
			return true;
		}

		$ratio = $summary->payableAmount / $orderTotal;
		return $ratio >= 0.1 && $ratio <= 10.0;
	}
}
