<?php
/**
 * Streaming RSS2 XML writer for Google Shopping Feed.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Marketing;

// Prevent direct access.
defined( 'ABSPATH' ) || exit;

/**
 * Writes the Google Shopping RSS2 feed file by streaming item-by-item.
 *
 * Streams to a file handle using fwrite() to avoid accumulating a large
 * string in memory. Safe for catalogues with 10,000+ products.
 *
 * @since 1.0.0
 */
final class Google_Xml_Writer {

	/**
	 * File handle.
	 *
	 * @since 1.0.0
	 *
	 * @var resource|null
	 */
	private mixed $handle = null;

	/**
	 * Constructor.
	 *
	 * @since 1.0.0
	 *
	 * @param string $file_path Absolute path to the output XML file.
	 */
	public function __construct( private string $file_path ) {}

	/**
	 * Open the file and write the RSS2 channel header.
	 *
	 * @since 1.0.0
	 *
	 * @throws \RuntimeException If the feed directory cannot be created or the file cannot be opened.
	 */
	public function open(): void {
		$dir = dirname( $this->file_path );
		if ( ! wp_mkdir_p( $dir ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new \RuntimeException( 'Kramar: Could not create feed directory: ' . $dir );
		}

		// Write index.php to prevent directory browsing.
		$index_file = $dir . '/index.php';
		if ( ! file_exists( $index_file ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			file_put_contents( $index_file, '<?php // Silence is golden.' );
		}

		// WP_Filesystem does not support streaming fwrite() — direct PHP file
		// operations are required for memory-safe large-catalogue generation.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$this->handle = fopen( $this->file_path . '.tmp', 'wb' );
		if ( false === $this->handle ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new \RuntimeException( 'Kramar: Could not open feed file for writing: ' . $this->file_path );
		}

		$title       = esc_html( get_bloginfo( 'name' ) );
		$link        = esc_url( home_url() );
		$description = 'Product feed for Google Merchant Center';

		$this->write( '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL );
		$this->write( '<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">' . PHP_EOL );
		$this->write( '  <channel>' . PHP_EOL );
		$this->write( "    <title>{$title}</title>" . PHP_EOL );
		$this->write( "    <link>{$link}</link>" . PHP_EOL );
		$this->write( "    <description>{$description}</description>" . PHP_EOL );
	}

	/**
	 * Write a single product item to the feed.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, string> $fields Google field name → value map.
	 *                                      Empty-string values are skipped.
	 *                                      'g:additional_image_link' is split on comma and written as multiple nodes.
	 */
	public function write_item( array $fields ): void {
		if ( null === $this->handle ) {
			return;
		}

		$this->write( '    <item>' . PHP_EOL );

		foreach ( $fields as $tag => $value ) {
			if ( '' === $value ) {
				continue;
			}

			// additional_image_link: split comma-separated URLs into separate nodes.
			if ( 'g:additional_image_link' === $tag ) {
				$urls = array_slice(
					array_filter( array_map( 'trim', explode( ',', $value ) ) ),
					0,
					10
				);
				foreach ( $urls as $url ) {
					$this->write_node( $tag, $url );
				}
				continue;
			}

			$this->write_node( $tag, $value );
		}

		$this->write( '    </item>' . PHP_EOL );
	}

	/**
	 * Write the closing channel/rss tags and close the file handle.
	 * Renames the temp file to the final path atomically.
	 *
	 * @since 1.0.0
	 */
	public function close(): void {
		if ( null === $this->handle ) {
			return;
		}

		$this->write( '  </channel>' . PHP_EOL );
		$this->write( '</rss>' . PHP_EOL );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		fclose( $this->handle );
		$this->handle = null;

		// Atomic rename: prevents serving a partial file if generation is interrupted.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
		rename( $this->file_path . '.tmp', $this->file_path );
	}

	/**
	 * Write a single XML element with CDATA content.
	 *
	 * @since 1.0.0
	 *
	 * @param string $tag   XML element name (e.g. 'g:title').
	 * @param string $value Element content.
	 */
	private function write_node( string $tag, string $value ): void {
		// Strip ]]> from value to prevent CDATA termination injection.
		$safe = str_replace( ']]>', ']]]]><![CDATA[>', $value );
		$this->write( "      <{$tag}><![CDATA[{$safe}]]></{$tag}>" . PHP_EOL );
	}

	/**
	 * Write a raw string to the file handle.
	 *
	 * @since 1.0.0
	 *
	 * @param string $line Content to write.
	 */
	private function write( string $line ): void {
		if ( null !== $this->handle ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
			fwrite( $this->handle, $line );
		}
	}
}
