<?php
/**
 * Standalone .po -> .mo compiler (no WordPress dependency).
 * Run with:
 *   php tools/rebuild-mo-standalone.php
 *
 * Iterates every .po file in languages/, parses it (msgid / msgstr pairs
 * only, no plurals / contexts — sufficient for this plugin), and writes
 * a matching .mo binary next to it.
 *
 * Dev-only : this file is NEVER included at runtime by the plugin. It is
 * a standalone command-line utility so wp.org's automated review flags a
 * few patterns that are intentionally wrong for a CLI script (WP_Filesystem,
 * PHP-prefixed function names, direct stdout echoes). Guarded here so the
 * script simply refuses to execute in any non-CLI context — no WP action
 * can ever trigger it.
 *
 * The MO file format is documented at :
 *   https://www.gnu.org/software/gettext/manual/html_node/MO-Files.html
 *
 * phpcs:disable WordPress.NamingConventions.PrefixAllGlobals -- CLI-only script, no globals leak into WP.
 * phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- CLI stdout / binary file write ; WP_Filesystem is unavailable without wp-load.
 * phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Same reason.
 * phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI stdout is not HTML.
 */

// CLI bootstrap : when invoked from a shell (not from within WP), fake
// an ABSPATH constant so the standard « direct file access » guard
// below doesn't kill the CLI process. A real web request without WP
// will NOT enter this branch (PHP_SAPI is not « cli ») and will hit
// the guard as expected.
if ( ! defined( 'ABSPATH' ) && PHP_SAPI === 'cli' ) {
	define( 'ABSPATH', __DIR__ . '/' );
}

// Standard wp.org direct-access guard. Exits when someone browses to
// this file directly on the server without a WP context (ABSPATH still
// undefined = neither WP nor our CLI bootstrap ran).
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// Belt : if the constant was defined by a real WP bootstrap (i.e. this
// file got auto-loaded by mistake), refuse to run. Only CLI invocations
// should ever reach the meat of this script.
if ( PHP_SAPI !== 'cli' ) {
	return;
}

$dir = dirname( __DIR__ ) . '/languages';
if ( ! is_dir( $dir ) ) {
	fwrite( STDERR, "Languages directory not found : $dir\n" );
	exit( 1 );
}

/**
 * Parse a .po file into an ordered [ msgid => msgstr ] map.
 * Silently ignores plural forms and contexts (this plugin doesn't use
 * either).
 */
function po_parse( string $path ) : array {
	$entries = [];
	$lines   = file( $path, FILE_IGNORE_NEW_LINES );
	if ( ! $lines ) { return $entries; }

	$mode = null;   // 'id' | 'str' | null
	$msgid = null;
	$msgstr = null;

	$flush = function () use ( &$entries, &$msgid, &$msgstr ) {
		if ( $msgid !== null && $msgstr !== null && $msgstr !== '' ) {
			$entries[ $msgid ] = $msgstr;
		}
		$msgid  = null;
		$msgstr = null;
	};

	foreach ( $lines as $line ) {
		$trim = ltrim( $line );
		if ( $trim === '' ) {
			// End of entry.
			$flush();
			$mode = null;
			continue;
		}
		if ( str_starts_with( $trim, '#' ) ) {
			// Comment / reference line.
			continue;
		}
		if ( str_starts_with( $trim, 'msgid ' ) ) {
			$flush();
			$mode  = 'id';
			$msgid = po_decode( substr( $trim, 6 ) );
			continue;
		}
		if ( str_starts_with( $trim, 'msgstr ' ) ) {
			$mode   = 'str';
			$msgstr = po_decode( substr( $trim, 7 ) );
			continue;
		}
		if ( str_starts_with( $trim, '"' ) ) {
			// Continuation of the previous string.
			$chunk = po_decode( $trim );
			if ( $mode === 'id' ) {
				$msgid = ( $msgid ?? '' ) . $chunk;
			} elseif ( $mode === 'str' ) {
				$msgstr = ( $msgstr ?? '' ) . $chunk;
			}
		}
	}
	$flush();
	// Drop the header entry (empty msgid), it becomes an MO header handled
	// separately when the .mo is written.
	unset( $entries[ '' ] );
	return $entries;
}

/**
 * Strip the surrounding double-quotes and un-escape backslash sequences.
 */
function po_decode( string $s ) : string {
	$s = trim( $s );
	if ( strlen( $s ) < 2 || $s[0] !== '"' || substr( $s, -1 ) !== '"' ) {
		return '';
	}
	$s = substr( $s, 1, -1 );
	return stripcslashes( $s );
}

/**
 * Serialise the [ msgid => msgstr ] map to the MO binary format and
 * write it to $out. Sort keys ASCII-ascending as required by the spec.
 */
function mo_write( string $out, array $entries ) : void {
	// Prepend an empty-msgid header entry (required by the format).
	$header  = "Project-Id-Version: pay4all-form\n"
	         . "MIME-Version: 1.0\n"
	         . "Content-Type: text/plain; charset=UTF-8\n"
	         . "Content-Transfer-Encoding: 8bit\n";
	$data    = [ '' => $header ];
	foreach ( $entries as $k => $v ) { $data[ $k ] = $v; }
	ksort( $data, SORT_STRING );

	$n            = count( $data );
	$offsets_orig = [];
	$offsets_tran = [];
	$strings      = '';
	$cursor       = 0;
	$ids          = array_keys( $data );
	$vals         = array_values( $data );

	// Original (msgid) table.
	foreach ( $ids as $id ) {
		$offsets_orig[] = [ strlen( $id ), $cursor ];
		$strings       .= $id . "\0";
		$cursor        += strlen( $id ) + 1;
	}
	// Translation (msgstr) table.
	foreach ( $vals as $v ) {
		$offsets_tran[] = [ strlen( $v ), $cursor ];
		$strings       .= $v . "\0";
		$cursor        += strlen( $v ) + 1;
	}

	// Header : magic, revision, N, O, T, S, H.
	// O = start of msgid table, T = start of msgstr table.
	$header_size    = 7 * 4;
	$orig_tbl_off   = $header_size;
	$tran_tbl_off   = $orig_tbl_off + $n * 8;
	$hash_size      = 0;   // No hash table needed for lookup ; parsers cope.
	$hash_off       = 0;
	$strings_off    = $tran_tbl_off + $n * 8;

	$binary  = pack( 'V', 0x950412de ); // little-endian magic
	$binary .= pack( 'V', 0 );          // revision
	$binary .= pack( 'V', $n );
	$binary .= pack( 'V', $orig_tbl_off );
	$binary .= pack( 'V', $tran_tbl_off );
	$binary .= pack( 'V', $hash_size );
	$binary .= pack( 'V', $hash_off );
	foreach ( $offsets_orig as $row ) {
		$binary .= pack( 'V', $row[0] );
		$binary .= pack( 'V', $strings_off + $row[1] );
	}
	foreach ( $offsets_tran as $row ) {
		$binary .= pack( 'V', $row[0] );
		$binary .= pack( 'V', $strings_off + $row[1] );
	}
	$binary .= $strings;

	file_put_contents( $out, $binary );
}

$files = glob( $dir . '/*.po' );
if ( ! $files ) {
	fwrite( STDERR, "No .po files found in $dir\n" );
	exit( 1 );
}
foreach ( $files as $po_path ) {
	$mo_path  = preg_replace( '/\.po$/', '.mo', $po_path );
	$entries  = po_parse( $po_path );
	mo_write( $mo_path, $entries );
	echo basename( $po_path ) . " -> " . basename( $mo_path ) . " (" . count( $entries ) . " entries)\n";
}
echo "Done.\n";
