<?php
/**
 * SEPA direct-debit XML (pain.008.001.08).
 *
 * Emits the ISO 20022 2019 message version required by the German banking
 * DFÜ-Abkommen Anlage 3 v26.11: from 15 Nov 2026 the older ISO 2009 versions
 * (pain.008.002.02, pain.008.003.02, pain.008.001.02) are no longer accepted
 * for submission, and structured addresses become mandatory. Files produced
 * here are valid both before and after that date.
 *
 * Written directly with XMLWriter rather than through a library: the message
 * subset a Verein needs is small and fixed, the current PHP SEPA libraries
 * still target the retiring versions, and owning the output byte-for-byte is
 * what makes the bundled validator meaningful.
 *
 * A bank rejects a batch as a whole, so every input is validated before a
 * single element is written — the caller gets errors, never a broken file.
 */

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

class BSTEW_Sepa_Writer {

	const NAMESPACE_2019 = 'urn:iso:std:iso:20022:tech:xsd:pain.008.001.08';

	/** @var string[] Validation errors collected before writing. */
	public $errors = array();

	/**
	 * @param array $creditor verein_name, street, postcode, city, country, iban, bic, creditor_id.
	 * @param array $debits   Each: name, iban, mandate_ref, mandate_date (Y-m-d),
	 *                        sequence_type, amount, purpose, street, postcode, city, country,
	 *                        end_to_end (optional).
	 * @param array $args     collection_date (Y-m-d), msg_id (optional).
	 * @return string XML, or '' with $this->errors filled.
	 */
	public function build( array $creditor, array $debits, array $args ) {
		$this->errors = array();

		$name = bstew_sepa_text( $creditor['verein_name'] ?? '' );
		if ( '' === $name ) {
			$this->errors[] = __( 'Organization name is missing (Settings).', 'beitrag-steward' );
		}
		if ( ! bstew_iban_valid( $creditor['iban'] ?? '' ) ) {
			$this->errors[] = __( 'The organization IBAN is invalid (Settings).', 'beitrag-steward' );
		}
		if ( ! bstew_creditor_id_valid( $creditor['creditor_id'] ?? '' ) ) {
			$this->errors[] = __( 'The creditor identifier (Gläubiger-ID) is invalid (Settings).', 'beitrag-steward' );
		}
		if ( '' === trim( $creditor['city'] ?? '' ) || '' === trim( $creditor['country'] ?? '' ) ) {
			$this->errors[] = __( 'The organization needs at least city and country: structured addresses are mandatory in SEPA files from 15 Nov 2026.', 'beitrag-steward' );
		}

		$collection_date = $args['collection_date'] ?? '';
		if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $collection_date ) || strtotime( $collection_date ) < strtotime( gmdate( 'Y-m-d' ) ) ) {
			$this->errors[] = __( 'The collection date must be today or later.', 'beitrag-steward' );
		}

		if ( empty( $debits ) ) {
			$this->errors[] = __( 'No members selected for collection.', 'beitrag-steward' );
		}

		foreach ( $debits as $i => $d ) {
			$who = bstew_sepa_text( $d['name'] ?? '' );
			$who = '' !== $who ? $who : '#' . ( $i + 1 );
			if ( ! bstew_iban_valid( $d['iban'] ?? '' ) ) {
				/* translators: %s: member name */
				$this->errors[] = sprintf( __( '%s: invalid IBAN.', 'beitrag-steward' ), $who );
			}
			if ( '' === trim( $d['mandate_ref'] ?? '' ) ) {
				/* translators: %s: member name */
				$this->errors[] = sprintf( __( '%s: missing mandate reference.', 'beitrag-steward' ), $who );
			}
			$md = $d['mandate_date'] ?? '';
			if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $md ) || strtotime( $md ) > time() ) {
				/* translators: %s: member name */
				$this->errors[] = sprintf( __( '%s: mandate date is missing or in the future.', 'beitrag-steward' ), $who );
			}
			$amount = (float) ( $d['amount'] ?? 0 );
			if ( $amount < 0.01 || $amount > 999999999.99 ) {
				/* translators: %s: member name */
				$this->errors[] = sprintf( __( '%s: amount must be at least 0.01 EUR.', 'beitrag-steward' ), $who );
			}
			if ( '' === trim( $d['city'] ?? '' ) || '' === trim( $d['country'] ?? '' ) ) {
				/* translators: %s: member name */
				$this->errors[] = sprintf( __( '%s: city and country are required (structured address, mandatory from 15 Nov 2026).', 'beitrag-steward' ), $who );
			}
		}

		if ( $this->errors ) {
			return '';
		}

		// Group by sequence type: mixing FRST and RCUR in one PmtInf is the
		// kind of thing some banks tolerate and others reject; one block per
		// type is always accepted.
		$groups = array();
		foreach ( $debits as $d ) {
			$seq = in_array( $d['sequence_type'] ?? 'RCUR', array( 'FRST', 'RCUR', 'OOFF', 'FNAL' ), true ) ? $d['sequence_type'] : 'RCUR';
			$groups[ $seq ][] = $d;
		}

		$msg_id = $args['msg_id'] ?? ( 'BST-' . gmdate( 'Ymd-His' ) . '-' . wp_rand( 100, 999 ) );
		$msg_id = substr( preg_replace( '/[^A-Za-z0-9\-]/', '', $msg_id ), 0, 35 );

		$total = 0.0;
		foreach ( $debits as $d ) {
			$total += (float) $d['amount'];
		}

		$x = new XMLWriter();
		$x->openMemory();
		$x->startDocument( '1.0', 'UTF-8' );
		$x->startElementNs( null, 'Document', self::NAMESPACE_2019 );
		$x->startElement( 'CstmrDrctDbtInitn' );

		$x->startElement( 'GrpHdr' );
		$this->el( $x, 'MsgId', $msg_id );
		$this->el( $x, 'CreDtTm', gmdate( 'Y-m-d\TH:i:s\Z' ) );
		$this->el( $x, 'NbOfTxs', (string) count( $debits ) );
		$this->el( $x, 'CtrlSum', number_format( $total, 2, '.', '' ) );
		$x->startElement( 'InitgPty' );
		$this->el( $x, 'Nm', $name );
		$x->endElement();
		$x->endElement(); // GrpHdr

		$n = 0;
		foreach ( $groups as $seq => $group ) {
			$n++;
			$group_total = 0.0;
			foreach ( $group as $d ) {
				$group_total += (float) $d['amount'];
			}

			$x->startElement( 'PmtInf' );
			$this->el( $x, 'PmtInfId', substr( $msg_id . '-P' . $n, 0, 35 ) );
			$this->el( $x, 'PmtMtd', 'DD' );
			$this->el( $x, 'BtchBookg', 'true' );
			$this->el( $x, 'NbOfTxs', (string) count( $group ) );
			$this->el( $x, 'CtrlSum', number_format( $group_total, 2, '.', '' ) );

			$x->startElement( 'PmtTpInf' );
			$x->startElement( 'SvcLvl' );
			$this->el( $x, 'Cd', 'SEPA' );
			$x->endElement();
			$x->startElement( 'LclInstrm' );
			$this->el( $x, 'Cd', 'CORE' );
			$x->endElement();
			$this->el( $x, 'SeqTp', $seq );
			$x->endElement();

			$this->el( $x, 'ReqdColltnDt', $collection_date );

			$x->startElement( 'Cdtr' );
			$this->el( $x, 'Nm', $name );
			$this->postal( $x, $creditor );
			$x->endElement();

			$x->startElement( 'CdtrAcct' );
			$x->startElement( 'Id' );
			$this->el( $x, 'IBAN', strtoupper( preg_replace( '/\s+/', '', $creditor['iban'] ) ) );
			$x->endElement();
			$x->endElement();

			$x->startElement( 'CdtrAgt' );
			$x->startElement( 'FinInstnId' );
			$bic = strtoupper( preg_replace( '/\s+/', '', $creditor['bic'] ?? '' ) );
			if ( '' !== $bic ) {
				$this->el( $x, 'BICFI', $bic );
			} else {
				$x->startElement( 'Othr' );
				$this->el( $x, 'Id', 'NOTPROVIDED' );
				$x->endElement();
			}
			$x->endElement();
			$x->endElement();

			$this->el( $x, 'ChrgBr', 'SLEV' );

			$x->startElement( 'CdtrSchmeId' );
			$x->startElement( 'Id' );
			$x->startElement( 'PrvtId' );
			$x->startElement( 'Othr' );
			$this->el( $x, 'Id', strtoupper( preg_replace( '/\s+/', '', $creditor['creditor_id'] ) ) );
			$x->startElement( 'SchmeNm' );
			$this->el( $x, 'Prtry', 'SEPA' );
			$x->endElement();
			$x->endElement();
			$x->endElement();
			$x->endElement();
			$x->endElement();

			foreach ( $group as $d ) {
				$x->startElement( 'DrctDbtTxInf' );

				$x->startElement( 'PmtId' );
				$e2e = substr( preg_replace( '/[^A-Za-z0-9\-]/', '', $d['end_to_end'] ?? '' ), 0, 35 );
				$this->el( $x, 'EndToEndId', '' !== $e2e ? $e2e : 'NOTPROVIDED' );
				$x->endElement();

				$x->startElement( 'InstdAmt' );
				$x->writeAttribute( 'Ccy', 'EUR' );
				$x->text( number_format( (float) $d['amount'], 2, '.', '' ) );
				$x->endElement();

				$x->startElement( 'DrctDbtTx' );
				$x->startElement( 'MndtRltdInf' );
				$this->el( $x, 'MndtId', bstew_sepa_text( $d['mandate_ref'], 35 ) );
				$this->el( $x, 'DtOfSgntr', $d['mandate_date'] );
				$x->endElement();
				$x->endElement();

				$x->startElement( 'DbtrAgt' );
				$x->startElement( 'FinInstnId' );
				$x->startElement( 'Othr' );
				$this->el( $x, 'Id', 'NOTPROVIDED' );
				$x->endElement();
				$x->endElement();
				$x->endElement();

				$x->startElement( 'Dbtr' );
				$this->el( $x, 'Nm', bstew_sepa_text( $d['name'] ) );
				$this->postal( $x, $d );
				$x->endElement();

				$x->startElement( 'DbtrAcct' );
				$x->startElement( 'Id' );
				$this->el( $x, 'IBAN', strtoupper( preg_replace( '/\s+/', '', $d['iban'] ) ) );
				$x->endElement();
				$x->endElement();

				if ( '' !== trim( $d['purpose'] ?? '' ) ) {
					$x->startElement( 'RmtInf' );
					$this->el( $x, 'Ustrd', bstew_sepa_text( $d['purpose'], 140 ) );
					$x->endElement();
				}

				$x->endElement(); // DrctDbtTxInf
			}

			$x->endElement(); // PmtInf
		}

		$x->endElement(); // CstmrDrctDbtInitn
		$x->endElement(); // Document
		$x->endDocument();

		return $x->outputMemory();
	}

	/**
	 * Structured postal address. Element order follows the schema sequence
	 * (StrtNm, PstCd, TwnNm, Ctry) — banks validate against the XSD, where
	 * order is not a style question.
	 */
	private function postal( XMLWriter $x, array $who ) {
		$x->startElement( 'PstlAdr' );
		$street = bstew_sepa_text( $who['street'] ?? '', 70 );
		if ( '' !== $street ) {
			$this->el( $x, 'StrtNm', $street );
		}
		$postcode = bstew_sepa_text( $who['postcode'] ?? '', 16 );
		if ( '' !== $postcode ) {
			$this->el( $x, 'PstCd', $postcode );
		}
		$this->el( $x, 'TwnNm', bstew_sepa_text( $who['city'] ?? '', 35 ) );
		$this->el( $x, 'Ctry', strtoupper( substr( $who['country'] ?? 'DE', 0, 2 ) ) );
		$x->endElement();
	}

	private function el( XMLWriter $x, $tag, $value ) {
		$x->startElement( $tag );
		$x->text( $value );
		$x->endElement();
	}
}
