<?php

declare( strict_types=1 );

namespace Brightfern\Validation;

use DOMDocument;
use Brightfern\Invoice\Fa3Serializer;
use Brightfern\Invoice\Model\Invoice;
use Brightfern\Invoice\Model\TaxSubtotal;
use Brightfern\Invoice\VatCategory;
use Brightfern\Support\Num;

/**
 * Layered pre-flight validation of a generated invoice. This is the "validate
 * before sending" differentiator. Three layers, strongest-portable first:
 *
 *   1. BUSINESS RULES (always on, pure PHP). Re-implements the most important,
 *      most-failed EN 16931 / Peppol rules — mandatory fields, the BR-CO-1x
 *      total reconciliations, and the category rules (S/E/AE/K/…). This catches
 *      the overwhelming majority of real rejections with zero dependencies and
 *      is fully unit-tested.
 *
 *   2. XSD (optional). If a UBL Invoice XSD is bundled, structural schema
 *      validation runs via libxml.
 *
 *   3. SCHEMATRON (optional, needs ext-xsl). If the official Peppol BIS
 *      schematron — precompiled to XSLT — is bundled, we transform the invoice
 *      to SVRL and surface every failed assertion. This is the authoritative
 *      rule layer an Access Point uses; we run the SAME rules locally.
 *
 * The provider's own validation endpoint is the final gate at send time
 * (see Transmission); layers 1–3 mean we almost never get that far with a bad
 * document. Configure the optional resource paths via the constructor.
 */
final class InvoiceValidator {

	/** Tolerance for monetary reconciliation: exactly one cent. */
	private const CENT = 0.005;

	public function __construct(
		private ?string $schematronXsltPath = null,
		// Not necessarily a UBL schema: whichever XSD matches the active format
		// (UBL for Peppol/XRechnung, FA(3) for KSeF, none for Factur-X's CII).
		private ?string $xsdPath = null,
	) {}

	/**
	 * Run every available layer.
	 *
	 * @param bool $requirePeppolAddress Whether missing seller/buyer electronic
	 *   addresses (BT-34/BT-49) should block the document. Only true when the
	 *   document is actually about to be transmitted over Peppol — a document
	 *   generated for manual download does not need Peppol routing.
	 * @param bool $requireBuyerReference Whether a missing buyer reference
	 *   (BT-10) should block the document. XRechnung's CIUS requires it
	 *   (routing / Leitweg-ID for B2G); Peppol BIS does not.
	 * @param bool $peppolBound Whether this document travels over Peppol at all.
	 *   False for the national formats — Factur-X (France's domestic PPF/PDP
	 *   flow) and FA(3) (Poland's KSeF) — where the Peppol electronic-address
	 *   rules simply don't apply and warning about them would be misleading noise.
	 * @param bool $ksefBound Whether this document is destined for Poland's KSeF,
	 *   which constrains what FA(3) can express (see {@see checkKsef()}).
	 */
	public function validate( Invoice $invoice, ?string $xml = null, bool $requirePeppolAddress = true, bool $requireBuyerReference = false, bool $peppolBound = true, bool $ksefBound = false ): ValidationResult {
		$result = $this->validateModel( $invoice, $requirePeppolAddress, $requireBuyerReference, $peppolBound, $ksefBound );

		if ( null !== $xml ) {
			$result->merge( $this->validateXml( $xml ) );
		}

		return $result;
	}

	/* ================================================================== *
	 * Layer 1 — business rules (pure, always on)
	 * ================================================================== */

	public function validateModel( Invoice $invoice, bool $requirePeppolAddress = true, bool $requireBuyerReference = false, bool $peppolBound = true, bool $ksefBound = false ): ValidationResult {
		$r = new ValidationResult();

		$this->checkMandatoryHeader( $invoice, $r );
		$this->checkParties( $invoice, $r );
		$this->checkLines( $invoice, $r );
		$this->checkVatBreakdown( $invoice, $r );
		$this->checkTotals( $invoice, $r );
		$this->checkPeppol( $invoice, $r, $requirePeppolAddress, $peppolBound );
		$this->checkBuyerReference( $invoice, $r, $requireBuyerReference );
		$this->checkKsef( $invoice, $r, $ksefBound );

		return $r;
	}

	private function checkMandatoryHeader( Invoice $inv, ValidationResult $r ): void {
		if ( '' === trim( $inv->number ) ) {
			$r->error( 'BR-02', 'Invoice number (BT-1) is required.', 'BT-1' );
		}
		if ( '' === trim( $inv->issueDate ) ) {
			$r->error( 'BR-03', 'Issue date (BT-2) is required.', 'BT-2' );
		}
		if ( '' === trim( $inv->typeCode ) ) {
			$r->error( 'BR-04', 'Invoice type code (BT-3) is required.', 'BT-3' );
		}
		if ( '' === trim( $inv->currency ) ) {
			$r->error( 'BR-05', 'Document currency (BT-5) is required.', 'BT-5' );
		}
	}

	private function checkParties( Invoice $inv, ValidationResult $r ): void {
		if ( '' === trim( $inv->seller->registrationName ) ) {
			$r->error( 'BR-06', 'Seller name (BT-27) is required.', 'BT-27' );
		}
		if ( '' === trim( $inv->buyer->registrationName ) ) {
			$r->error( 'BR-07', 'Buyer name (BT-44) is required.', 'BT-44' );
		}
		if ( ! $inv->seller->address || '' === trim( $inv->seller->address->countryCode ) ) {
			$r->error( 'BR-09', 'Seller country code (BT-40) is required.', 'BT-40' );
		}
		if ( ! $inv->buyer->address || '' === trim( $inv->buyer->address->countryCode ) ) {
			$r->error( 'BR-11', 'Buyer country code (BT-55) is required.', 'BT-55' );
		}
	}

	private function checkLines( Invoice $inv, ValidationResult $r ): void {
		if ( count( $inv->lines ) === 0 ) {
			$r->error( 'BR-16', 'An invoice must have at least one line (BG-25).', 'BG-25' );
			return;
		}

		foreach ( $inv->lines as $i => $line ) {
			$loc = 'BG-25[' . ( $i + 1 ) . ']';
			if ( '' === trim( $line->id ) ) {
				$r->error( 'BR-21', 'Each line needs an identifier (BT-126).', $loc );
			}
			if ( '' === trim( $line->name ) ) {
				$r->error( 'BR-25', 'Each line needs an item name (BT-153).', $loc );
			}
			if ( '' === trim( $line->vatCategoryCode ) ) {
				$r->error( 'BR-CO-04', 'Each line needs a VAT category code (BT-151).', $loc );
			}
			if ( VatCategory::STANDARD === $line->vatCategoryCode && ! ( $line->vatPercent > 0.0 ) ) {
				$r->error( 'BR-S-05', 'A standard-rated line (S) must have a VAT rate greater than zero (BT-152).', $loc );
			}
		}
	}

	private function checkVatBreakdown( Invoice $inv, ValidationResult $r ): void {
		if ( count( $inv->taxSubtotals ) === 0 ) {
			$r->error( 'BR-CO-18', 'The invoice must contain at least one VAT breakdown group (BG-23).', 'BG-23' );
			return;
		}

		$hasStandard = false;

		foreach ( $inv->taxSubtotals as $row ) {
			$loc = 'BG-23/' . $row->categoryCode;

			// BR-CO-17: per-category VAT amount = taxable × rate, rounded.
			$expected = Num::money( $row->taxableAmount * $row->percent / 100 );
			if ( abs( $expected - $row->taxAmount ) > self::CENT ) {
				$r->error(
					'BR-CO-17',
					sprintf(
						'VAT amount for category %s @ %s%% should be %s but is %s.',
						$row->categoryCode,
						Num::percent( $row->percent ),
						Num::amount( $expected ),
						Num::amount( $row->taxAmount )
					),
					$loc
				);
			}

			// Exemption reason required for E/AE/K/G/O (BR-E-10 / BR-AE-10 / …).
			if ( VatCategory::requiresExemptionReason( $row->categoryCode ) ) {
				$hasReason = ( null !== $row->exemptionReasonText && '' !== $row->exemptionReasonText )
					|| ( null !== $row->exemptionReasonCode && '' !== $row->exemptionReasonCode );
				if ( ! $hasReason ) {
					$r->error(
						'BR-' . $row->categoryCode . '-10',
						sprintf( 'VAT category %s requires an exemption reason or code (BT-120/BT-121).', $row->categoryCode ),
						$loc
					);
				}
			}

			if ( VatCategory::STANDARD === $row->categoryCode ) {
				$hasStandard = true;
			}

			// Intra-community supply requires the buyer VAT id (BR-IC-*).
			if ( VatCategory::INTRA_COMMUNITY === $row->categoryCode && ! $inv->buyer->hasVat() ) {
				$r->error( 'BR-IC-05', 'Intra-community supply (category K) requires the buyer VAT identifier (BT-48).', 'BT-48' );
			}
		}

		$this->checkChargeOnlyRates( $inv, $r );

		// Standard-rated lines require a seller VAT identifier (BR-S-02).
		if ( $hasStandard && ! $inv->seller->hasVat() ) {
			$r->error( 'BR-S-02', 'Standard-rated invoices require a seller VAT identifier (BT-31).', 'BT-31' );
		}
	}

	/**
	 * A document-level allowance/charge (BG-20/BG-21) legitimately carries its
	 * own VAT category and rate, independent of any invoice line's — EN 16931's
	 * BG-23 VAT breakdown is one group per (category, rate) pair used ANYWHERE
	 * on the document, and OrderCharge's own docblock states this is deliberate
	 * design ("Each carries its own VAT rate so it lands in the correct VAT
	 * breakdown row"): reduced-rate shipping on a standard-rated order is a
	 * completely normal, spec-conformant document. So this is deliberately a
	 * WARNING, not an error, and does not change what gets emitted — forcing a
	 * charge onto a line's rate would break that legitimate case.
	 *
	 * What it DOES catch: WooOrderAdapter derives a fee/shipping charge's rate
	 * independently (tax ÷ net on that single WooCommerce line item, with no
	 * cross-check against the order's product lines), so a rate that matches no
	 * line can also mean a WooCommerce tax-class misconfiguration or a rounding
	 * artefact rather than a genuinely different applicable rate. Surfacing it
	 * lets the merchant notice and confirm one way or the other.
	 */
	private function checkChargeOnlyRates( Invoice $inv, ValidationResult $r ): void {
		$lineKeys = array();
		foreach ( $inv->lines as $line ) {
			$lineKeys[ TaxSubtotal::key( $line->vatCategoryCode, $line->vatPercent ) ] = true;
		}

		foreach ( $inv->taxSubtotals as $row ) {
			if ( isset( $lineKeys[ $row->keyForSelf() ] ) ) {
				continue;
			}
			$r->warning(
				'BRIGHTFERN-CHARGE-RATE',
				sprintf(
					'VAT breakdown row %s @ %s%% comes only from a document-level charge or allowance (e.g. shipping/a fee) — no invoice line uses this rate. Confirm this is the intended rate, not a WooCommerce tax-class mismatch.',
					$row->categoryCode,
					Num::percent( $row->percent )
				),
				'BG-23/' . $row->categoryCode
			);
		}
	}

	private function checkTotals( Invoice $inv, ValidationResult $r ): void {
		$t = $inv->totals;

		$lineSum = 0.0;
		foreach ( $inv->lines as $line ) {
			$lineSum += $line->lineExtensionAmount;
		}
		$this->reconcile( $r, 'BR-CO-10', 'Sum of line net amounts (BT-106)', $t->lineExtensionAmount, $lineSum );

		$expectedExcl = $t->lineExtensionAmount - $t->allowanceTotalAmount + $t->chargeTotalAmount;
		$this->reconcile( $r, 'BR-CO-13', 'Tax exclusive amount (BT-109)', $t->taxExclusiveAmount, $expectedExcl );

		$taxSum = 0.0;
		foreach ( $inv->taxSubtotals as $row ) {
			$taxSum += $row->taxAmount;
		}
		$this->reconcile( $r, 'BR-CO-14', 'Total VAT amount (BT-110)', $t->taxTotalAmount, $taxSum );

		$expectedIncl = $t->taxExclusiveAmount + $t->taxTotalAmount;
		$this->reconcile( $r, 'BR-CO-15', 'Tax inclusive amount (BT-112)', $t->taxInclusiveAmount, $expectedIncl );

		$expectedPayable = $t->taxInclusiveAmount - $t->prepaidAmount + $t->payableRoundingAmount;
		$this->reconcile( $r, 'BR-CO-16', 'Amount due for payment (BT-115)', $t->payableAmount, $expectedPayable );
	}

	private function reconcile( ValidationResult $r, string $code, string $label, float $actual, float $expected ): void {
		if ( abs( $actual - $expected ) > self::CENT ) {
			$r->error(
				$code,
				sprintf( '%s should be %s but is %s.', $label, Num::amount( $expected ), Num::amount( $actual ) ),
				$code
			);
		}
	}

	private function checkPeppol( Invoice $inv, ValidationResult $r, bool $requirePeppolAddress, bool $peppolBound = true ): void {
		// Peppol routing needs both electronic addresses (BT-34 / BT-49). Three
		// cases, deliberately distinct:
		//   - about to transmit over Peppol   -> missing address is an ERROR
		//   - Peppol document, download only  -> WARNING (it'll matter later)
		//   - not a Peppol document at all    -> silent; a Factur-X PDF headed
		//     for France's PPF/PDP has no Peppol address and never needs one, so
		//     warning about it on every invoice would be pure noise.
		if ( $peppolBound ) {
			if ( '' === trim( $inv->seller->endpointId ) ) {
				if ( $requirePeppolAddress ) {
					$r->error( 'PEPPOL-EN16931-R020', 'Seller electronic address (BT-34) is required for Peppol.', 'BT-34' );
				} else {
					$r->warning( 'PEPPOL-EN16931-R020', 'Seller electronic address (BT-34) is missing; required before this invoice can be sent over Peppol.', 'BT-34' );
				}
			}
			if ( '' === trim( $inv->buyer->endpointId ) ) {
				if ( $requirePeppolAddress ) {
					$r->error( 'PEPPOL-EN16931-R010', 'Buyer electronic address (BT-49) is required for Peppol.', 'BT-49' );
				} else {
					$r->warning( 'PEPPOL-EN16931-R010', 'Buyer electronic address (BT-49) is missing; required before this invoice can be sent over Peppol.', 'BT-49' );
				}
			}
		}

		// Credit transfer means a payment account is expected (BT-84). Applies to
		// every format — it's about being payable, not about Peppol routing.
		$transferMeans = in_array( $inv->payment->meansCode, array( '30', '58' ), true );
		if ( $transferMeans && '' === trim( $inv->payment->payeeIban ) ) {
			$r->warning( 'PEPPOL-EN16931-R061', 'Credit transfer (BT-81) usually requires a payment account identifier (BT-84).', 'BT-84' );
		}
	}

	/**
	 * XRechnung's CIUS requires a buyer reference (BT-10, typically a
	 * Leitweg-ID for B2G routing) — Peppol BIS does not. Only blocks when
	 * the caller says so (format === xrechnung); otherwise it's a no-op,
	 * same shape as {@see checkPeppol()}.
	 */
	private function checkBuyerReference( Invoice $inv, ValidationResult $r, bool $requireBuyerReference ): void {
		if ( ! $requireBuyerReference ) {
			return;
		}
		if ( '' === trim( $inv->buyerReference ) ) {
			$r->error( 'BR-DE-15', 'Buyer reference (BT-10, e.g. a Leitweg-ID) is required for XRechnung.', 'BT-10' );
		}
	}

	/**
	 * Constraints that come from FA(3) itself rather than from EN 16931.
	 *
	 * These are raised here so the merchant sees them in the pre-flight findings
	 * on the order screen, instead of meeting them as a generation failure —
	 * {@see \Brightfern\Invoice\Fa3Serializer} throws on the same two conditions,
	 * and the rate table is shared rather than restated.
	 */
	private function checkKsef( Invoice $inv, ValidationResult $r, bool $ksefBound ): void {
		if ( ! $ksefBound ) {
			return;
		}

		// FA(3)'s Podmiot1 node requires a NIP outright: the schema itself says
		// KSeF is for taxpayers registered in Poland.
		if ( null === Fa3Serializer::polishNip( $inv->seller->vatNumber ) ) {
			$r->error(
				'KSEF-01',
				'KSeF requires a Polish seller: set a valid Polish VAT number (NIP) in Brightfern settings.',
				'BT-31'
			);
		}

		// Every VAT rate has to land in one of the statutory buckets. Folding an
		// unknown rate into the nearest one would misstate the VAT return, so the
		// serializer refuses — warn about it before it gets that far.
		foreach ( $inv->taxSubtotals as $row ) {
			if ( null === Fa3Serializer::rateBucket( $row->categoryCode, $row->percent ) ) {
				$r->error(
					'KSEF-02',
					sprintf(
						'VAT rate %s%% (category %s) has no equivalent in the Polish FA(3) rate scheme.',
						Num::percent( $row->percent ),
						$row->categoryCode
					),
					'BT-119'
				);
			}
		}

		// FA(3)'s Podmiot1/Adres is mandatory, and unlike the buyer's it cannot be
		// omitted. Note that sellerIdentityComplete() does NOT cover the street or
		// city, so a merchant can reach this point with an address the invoice has
		// to render as a placeholder — legal-looking, but useless to a reader.
		$sellerAddress = $inv->seller->address;
		if ( ! $sellerAddress ) {
			$r->error( 'KSEF-04', 'KSeF requires a seller address: complete the address in Brightfern settings.', 'BG-5' );
		} elseif ( '' === trim( $sellerAddress->line1 ) && '' === trim( $sellerAddress->city ) ) {
			$r->warning( 'KSEF-04', 'Seller street and city are empty; the invoice will carry a placeholder address.', 'BG-5' );
		}

		// A korekta that cannot name the invoice it corrects is not a correction:
		// NrFaKorygowanej is mandatory and the serializer refuses without it.
		if ( $inv->isCreditNote() && '' === trim( $inv->precedingInvoiceNumber ) ) {
			$r->error( 'KSEF-05', 'A KSeF correction invoice must reference the number of the invoice it corrects.', 'BT-25' );
		}

		// Foreign-currency invoices must additionally restate the VAT in PLN
		// (P_14_xW), which needs an NBP reference rate Brightfern does not fetch.
		if ( 'PLN' !== strtoupper( trim( $inv->currency ) ) ) {
			$r->warning(
				'KSEF-03',
				'Invoice is not in PLN. FA(3) also expects the VAT restated in PLN at the NBP rate, which Brightfern does not calculate — add it before filing.',
				'BT-5'
			);
		}
	}

	/* ================================================================== *
	 * Layer 2 + 3 — XSD and Schematron over the rendered XML
	 * ================================================================== */

	public function validateXml( string $xml ): ValidationResult {
		$r = new ValidationResult();

		$doc = new DOMDocument();
		$prev = libxml_use_internal_errors( true );
		if ( ! $doc->loadXML( $xml ) ) {
			$r->error( 'XML-PARSE', 'Generated invoice XML is not well-formed.' );
			libxml_clear_errors();
			libxml_use_internal_errors( $prev );
			return $r;
		}

		// Layer 2: XSD.
		if ( null !== $this->xsdPath && is_readable( $this->xsdPath ) ) {
			if ( ! $doc->schemaValidate( $this->xsdPath ) ) {
				foreach ( libxml_get_errors() as $err ) {
					$r->error( 'XSD', trim( $err->message ), 'line ' . $err->line );
				}
			}
			libxml_clear_errors();
		}
		libxml_use_internal_errors( $prev );

		// Layer 3: Schematron via XSLT → SVRL.
		$r->merge( $this->runSchematron( $doc ) );

		return $r;
	}

	private function runSchematron( DOMDocument $doc ): ValidationResult {
		$r = new ValidationResult();

		if ( null === $this->schematronXsltPath || ! is_readable( $this->schematronXsltPath ) ) {
			$r->info( 'SCHEMATRON-SKIP', 'Bundled schematron not configured; relied on business-rule + provider validation.' );
			return $r;
		}
		if ( ! class_exists( \XSLTProcessor::class ) ) {
			$r->info( 'SCHEMATRON-NOXSL', 'PHP ext-xsl not available; schematron skipped. Business-rule + provider validation still apply.' );
			return $r;
		}

		$xsl = new DOMDocument();
		if ( ! $xsl->load( $this->schematronXsltPath ) ) {
			$r->warning( 'SCHEMATRON-LOAD', 'Could not load the schematron XSLT; skipped.' );
			return $r;
		}

		$proc = new \XSLTProcessor();
		$proc->importStylesheet( $xsl );
		$svrlXml = $proc->transformToXml( $doc );
		if ( false === $svrlXml || null === $svrlXml ) {
			$r->warning( 'SCHEMATRON-RUN', 'Schematron transform produced no output; skipped.' );
			return $r;
		}

		return $this->parseSvrl( $svrlXml, $r );
	}

	/**
	 * Parse SVRL (Schematron Validation Report Language) output: every
	 * <svrl:failed-assert> is a rule violation; @flag/@role "warning" downgrades
	 * to a warning, otherwise it is an error.
	 */
	private function parseSvrl( string $svrlXml, ValidationResult $r ): ValidationResult {
		$svrl = new DOMDocument();
		$prev = libxml_use_internal_errors( true );
		if ( ! $svrl->loadXML( $svrlXml ) ) {
			libxml_clear_errors();
			libxml_use_internal_errors( $prev );
			$r->warning( 'SCHEMATRON-PARSE', 'Could not parse schematron SVRL output.' );
			return $r;
		}
		libxml_use_internal_errors( $prev );

		$ns      = 'http://purl.oclc.org/dsdl/svrl';
		$asserts = $svrl->getElementsByTagNameNS( $ns, 'failed-assert' );

		foreach ( $asserts as $assert ) {
			$flag = strtolower( $assert->getAttribute( 'flag' ) . $assert->getAttribute( 'role' ) );
			$id   = $assert->getAttribute( 'id' );
			$loc  = $assert->getAttribute( 'location' );

			$textNodes = $assert->getElementsByTagNameNS( $ns, 'text' );
			$message   = $textNodes->length ? trim( $textNodes->item( 0 )->textContent ) : 'Schematron assertion failed.';

			if ( str_contains( $flag, 'warning' ) ) {
				$r->warning( $id !== '' ? $id : 'SCHEMATRON', $message, $loc );
			} else {
				$r->error( $id !== '' ? $id : 'SCHEMATRON', $message, $loc );
			}
		}

		return $r;
	}
}
