<?php
/**
 * SEPA direct-debit file validator.
 *
 * The reason this plugin exists: banks reject whole batches over one bad
 * element, the on-screen error ("XML-Dokument nicht gültig") says nothing,
 * and from 15 Nov 2026 (DFÜ-Abkommen Anlage 3, v26.11) German banks stop
 * accepting the ISO 2009 message versions entirely and require structured
 * addresses. A treasurer should learn all of that HERE, before upload.
 *
 * Checks are three-layered:
 *  1. Version verdict from the namespace (the Nov 2026 question).
 *  2. XSD validation when a schema for that version is bundled.
 *  3. Content rules no XSD can express: IBAN check digits, creditor-ID
 *     format, control sums vs. transaction sums, counts, mandate dates,
 *     collection dates, SEPA character set, address structure.
 *
 * Findings carry a severity: 'reject' (the bank will refuse this file),
 * 'deadline' (works today, dies on 15 Nov 2026), 'warn' (accepted but risky).
 */

if ( ! defined( 'ABSPATH' ) ) {
	die;
}

class BSTEW_Validator {

	const RETIRING = array(
		'urn:iso:std:iso:20022:tech:xsd:pain.008.002.02',
		'urn:iso:std:iso:20022:tech:xsd:pain.008.003.02',
		'urn:iso:std:iso:20022:tech:xsd:pain.008.001.02',
	);
	const CURRENT  = 'urn:iso:std:iso:20022:tech:xsd:pain.008.001.08';

	/**
	 * @param string $xml Raw file contents.
	 * @return array { findings: array{severity,message}[], version: string, ok: bool }
	 */
	public function validate( $xml ) {
		$findings = array();

		$prev = libxml_use_internal_errors( true );
		$doc  = new DOMDocument();
		$ok   = $doc->loadXML( $xml, LIBXML_NONET );
		if ( ! $ok ) {
			$err = libxml_get_last_error();
			libxml_clear_errors();
			libxml_use_internal_errors( $prev );
			return array(
				'version'  => '',
				'ok'       => false,
				'findings' => array( array(
					'severity' => 'reject',
					'message'  => sprintf(
						/* translators: %s: XML parser error */
						__( 'Not well-formed XML: %s', 'beitrag-steward' ),
						$err ? trim( $err->message ) : __( 'unknown parse error', 'beitrag-steward' )
					),
				) ),
			);
		}

		$ns = $doc->documentElement ? (string) $doc->documentElement->namespaceURI : '';

		if ( in_array( $ns, self::RETIRING, true ) ) {
			$findings[] = array(
				'severity' => 'deadline',
				'message'  => sprintf(
					/* translators: %s: message version, e.g. pain.008.003.02 */
					__( 'This file uses %s (ISO 2009). German banks stop accepting this version on 15 November 2026 (DFÜ-Abkommen Anlage 3, v26.11). It must be produced as pain.008.001.08 from then on.', 'beitrag-steward' ),
					basename( str_replace( ':', '/', $ns ) )
				),
			);
		} elseif ( self::CURRENT === $ns ) {
			// Current version, nothing to flag.
			$findings[] = array(
				'severity' => 'info',
				'message'  => __( 'Message version pain.008.001.08 — the version required from 15 November 2026.', 'beitrag-steward' ),
			);
		} elseif ( false !== strpos( $ns, 'pain.008' ) ) {
			$findings[] = array(
				'severity' => 'warn',
				'message'  => sprintf(
					/* translators: %s: XML namespace */
					__( 'Unrecognized pain.008 version (%s). Ask your bank whether it is accepted.', 'beitrag-steward' ),
					$ns
				),
			);
		} else {
			libxml_use_internal_errors( $prev );
			return array(
				'version'  => $ns,
				'ok'       => false,
				'findings' => array( array(
					'severity' => 'reject',
					'message'  => __( 'This is not a SEPA direct-debit file (no pain.008 namespace found).', 'beitrag-steward' ),
				) ),
			);
		}

		// Layer 2: XSD, when we ship one for this version.
		$xsd = BSTEW_PATH . 'includes/xsd/' . basename( str_replace( ':', '/', $ns ) ) . '.xsd';
		if ( file_exists( $xsd ) ) {
			if ( ! $doc->schemaValidate( $xsd ) ) {
				foreach ( array_slice( libxml_get_errors(), 0, 10 ) as $e ) {
					$findings[] = array(
						'severity' => 'reject',
						/* translators: 1: line number, 2: schema error */
						'message'  => sprintf( __( 'Schema violation (line %1$d): %2$s', 'beitrag-steward' ), $e->line, trim( $e->message ) ),
					);
				}
				libxml_clear_errors();
			}
		}

		// Layer 3: content rules, namespace-agnostic.
		$xp = new DOMXPath( $doc );
		$xp->registerNamespace( 'p', $ns );

		$get = function ( $expr, $ctx = null ) use ( $xp ) {
			$n = $xp->query( $expr, $ctx );
			return $n && $n->length ? trim( $n->item( 0 )->textContent ) : '';
		};

		$tx_nodes = $xp->query( '//p:DrctDbtTxInf' );
		$tx_count = $tx_nodes ? $tx_nodes->length : 0;

		$declared = (int) $get( '//p:GrpHdr/p:NbOfTxs' );
		if ( $declared !== $tx_count ) {
			$findings[] = array(
				'severity' => 'reject',
				/* translators: 1: declared count, 2: actual count */
				'message'  => sprintf( __( 'GrpHdr declares %1$d transactions but the file contains %2$d.', 'beitrag-steward' ), $declared, $tx_count ),
			);
		}

		$sum = 0.0;
		foreach ( $tx_nodes as $tx ) {
			$amt  = $get( 'p:InstdAmt', $tx );
			$sum += (float) $amt;
			$ccy  = '';
			$node = $xp->query( 'p:InstdAmt', $tx )->item( 0 );
			if ( $node ) {
				$ccy = $node->getAttribute( 'Ccy' );
			}
			if ( 'EUR' !== $ccy ) {
				$findings[] = array(
					'severity' => 'reject',
					'message'  => __( 'A transaction amount is not in EUR — SEPA core direct debit is EUR only.', 'beitrag-steward' ),
				);
			}
			if ( (float) $amt < 0.01 ) {
				$findings[] = array(
					'severity' => 'reject',
					'message'  => __( 'A transaction amount is below 0.01 EUR.', 'beitrag-steward' ),
				);
			}
		}
		$ctrl = $get( '//p:GrpHdr/p:CtrlSum' );
		if ( '' !== $ctrl && abs( (float) $ctrl - $sum ) > 0.005 ) {
			$findings[] = array(
				'severity' => 'reject',
				/* translators: 1: declared sum, 2: computed sum */
				'message'  => sprintf( __( 'Control sum %1$s does not match the sum of amounts (%2$s).', 'beitrag-steward' ), $ctrl, number_format( $sum, 2, '.', '' ) ),
			);
		}

		foreach ( $xp->query( '//p:IBAN' ) as $iban_node ) {
			$iban = trim( $iban_node->textContent );
			if ( ! bstew_iban_valid( $iban ) ) {
				$findings[] = array(
					'severity' => 'reject',
					/* translators: %s: masked IBAN */
					'message'  => sprintf( __( 'IBAN %s fails its check digits — the bank will reject the whole batch over this.', 'beitrag-steward' ), BSTEW_Crypto::mask_iban( $iban ) ),
				);
			}
		}

		$cred = $get( '//p:CdtrSchmeId//p:Othr/p:Id' );
		if ( '' === $cred ) {
			$findings[] = array(
				'severity' => 'reject',
				'message'  => __( 'No creditor identifier (Gläubiger-ID) in the file.', 'beitrag-steward' ),
			);
		} elseif ( ! bstew_creditor_id_valid( $cred ) ) {
			$findings[] = array(
				'severity' => 'reject',
				/* translators: %s: creditor id */
				'message'  => sprintf( __( 'Creditor identifier "%s" does not look valid.', 'beitrag-steward' ), esc_html( $cred ) ),
			);
		}

		foreach ( $xp->query( '//p:MndtRltdInf' ) as $mandate ) {
			if ( '' === $get( 'p:MndtId', $mandate ) ) {
				$findings[] = array(
					'severity' => 'reject',
					'message'  => __( 'A transaction is missing its mandate reference (MndtId).', 'beitrag-steward' ),
				);
			}
			$signed = $get( 'p:DtOfSgntr', $mandate );
			if ( '' === $signed ) {
				$findings[] = array(
					'severity' => 'reject',
					'message'  => __( 'A transaction is missing the mandate signature date (DtOfSgntr).', 'beitrag-steward' ),
				);
			} elseif ( strtotime( $signed ) > time() ) {
				$findings[] = array(
					'severity' => 'reject',
					'message'  => __( 'A mandate signature date lies in the future.', 'beitrag-steward' ),
				);
			}
		}

		$coll = $get( '//p:ReqdColltnDt' );
		if ( '' !== $coll && strtotime( $coll ) < strtotime( gmdate( 'Y-m-d' ) ) ) {
			$findings[] = array(
				'severity' => 'warn',
				/* translators: %s: date */
				'message'  => sprintf( __( 'Collection date %s is in the past. Banks reject past collection dates at submission time.', 'beitrag-steward' ), $coll ),
			);
		}

		// Structured addresses: the second half of the Nov 2026 change.
		$missing_addr = 0;
		foreach ( array( '//p:Cdtr', '//p:Dbtr' ) as $party_expr ) {
			foreach ( $xp->query( $party_expr ) as $party ) {
				$town    = $get( 'p:PstlAdr/p:TwnNm', $party );
				$country = $get( 'p:PstlAdr/p:Ctry', $party );
				if ( '' === $town || '' === $country ) {
					$missing_addr++;
				}
			}
		}
		if ( $missing_addr > 0 ) {
			$findings[] = array(
				'severity' => 'deadline',
				/* translators: %d: number of parties */
				'message'  => sprintf( __( '%d part(y/ies) have no structured address (at minimum town and country). Structured addresses are mandatory from 15 November 2026.', 'beitrag-steward' ), $missing_addr ),
			);
		}
		if ( $xp->query( '//p:PstlAdr/p:AdrLine' )->length > 0 ) {
			$findings[] = array(
				'severity' => 'deadline',
				'message'  => __( 'The file uses unstructured address lines (AdrLine). These are being phased out; use structured fields (street, postcode, town, country).', 'beitrag-steward' ),
			);
		}

		libxml_use_internal_errors( $prev );

		$has_reject = false;
		foreach ( $findings as $f ) {
			if ( 'reject' === $f['severity'] ) {
				$has_reject = true;
			}
		}

		// Sort by severity so the reader sees what blocks the file first.
		$rank = array( 'reject' => 0, 'deadline' => 1, 'warn' => 2, 'info' => 3 );
		usort(
			$findings,
			static function ( $a, $b ) use ( $rank ) {
				return ( $rank[ $a['severity'] ] ?? 9 ) <=> ( $rank[ $b['severity'] ] ?? 9 );
			}
		);

		return array(
			'version'  => $ns,
			'ok'       => ! $has_reject,
			'findings' => $findings,
		);
	}
}
