<?php
/**
 * Safe auto-reply merge tag rendering.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\AutoReplies;

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

/**
 * Renders the shared auto-reply merge tag vocabulary for subjects and HTML.
 */
class AutoReplyMergeTagRenderer {
	const FORMAT_HTML    = 'html';
	const FORMAT_SUBJECT = 'subject';
	const DEFAULT_MAX_OUTPUT_BYTES = 1048576;
	const MAX_VALUE_INPUT_BYTES = 1048576;

	/**
	 * Supported non-field merge tags.
	 *
	 * @var array<int,string>
	 */
	private $context_tags = array(
		'site_name',
		'form_name',
		'submission_date',
		'lead_id',
		'visitor_email',
	);

	/**
	 * Render a mail subject and return diagnostics for preview surfaces.
	 *
	 * @param mixed               $subject Subject template.
	 * @param array<string,mixed> $context Merge context.
	 * @param array<mixed>        $fields Associative field map or field rows.
	 * @param int                  $max_bytes Optional rendered output budget.
	 * @return array{content:string,diagnostics:array{used:array<int,string>,missing:array<int,string>,unknown:array<int,string>},overflowed:bool}
	 */
	public function render_subject( $subject, array $context = array(), array $fields = array(), $max_bytes = 0 ) {
		return $this->render( $subject, $context, $fields, self::FORMAT_SUBJECT, $max_bytes );
	}

	/**
	 * Render safe email HTML and return diagnostics for preview surfaces.
	 *
	 * @param mixed               $html HTML template.
	 * @param array<string,mixed> $context Merge context.
	 * @param array<mixed>        $fields Associative field map or field rows.
	 * @param int                  $max_bytes Optional rendered output budget.
	 * @return array{content:string,diagnostics:array{used:array<int,string>,missing:array<int,string>,unknown:array<int,string>},overflowed:bool}
	 */
	public function render_html( $html, array $context = array(), array $fields = array(), $max_bytes = 0 ) {
		return $this->render( $html, $context, $fields, self::FORMAT_HTML, $max_bytes );
	}

	/**
	 * Render merge tags in one of the supported output formats.
	 *
	 * Callers may provide fields separately or as $context['fields']. Separate
	 * fields take precedence. Diagnostics contain canonical brace-wrapped tags in
	 * their first-seen order and never include duplicates.
	 *
	 * @param mixed               $content Content template.
	 * @param array<string,mixed> $context Merge context.
	 * @param array<mixed>        $fields Associative field map or field rows.
	 * @param string              $format Output format.
	 * @param int                 $max_bytes Optional rendered output budget.
	 * @return array{content:string,diagnostics:array{used:array<int,string>,missing:array<int,string>,unknown:array<int,string>},overflowed:bool}
	 */
	public function render( $content, array $context = array(), array $fields = array(), $format = self::FORMAT_HTML, $max_bytes = 0 ) {
		$format = self::FORMAT_SUBJECT === strtolower( (string) $format ) ? self::FORMAT_SUBJECT : self::FORMAT_HTML;
		$content = is_scalar( $content ) ? (string) $content : '';
		$max_bytes = is_numeric( $max_bytes ) ? (int) $max_bytes : 0;
		if ( $max_bytes <= 0 ) {
			$max_bytes = self::DEFAULT_MAX_OUTPUT_BYTES;
		}

		if ( self::FORMAT_HTML === $format ) {
			$content = $this->remove_executable_template_syntax( $content );
		}

		if ( empty( $fields ) && isset( $context['fields'] ) && is_array( $context['fields'] ) ) {
			$fields = $context['fields'];
		}

		$field_overflows = array();
		$field_values    = $this->normalize_fields( $fields, $field_overflows );
		$diagnostics = array(
			'used'    => array(),
			'missing' => array(),
			'unknown' => array(),
		);

		$rendered  = '';
		$offset    = 0;
		$overflowed = false;
		$pattern   = '/\{([A-Za-z][A-Za-z0-9_-]*(?::[A-Za-z0-9_-]+)?)\}/';
		$content_length = strlen( $content );
		while ( $offset < $content_length && 1 === preg_match( $pattern, $content, $match, PREG_OFFSET_CAPTURE, $offset ) ) {
			$matched_token = (string) ( $match[0][0] ?? '' );
			$match_offset  = (int) ( $match[0][1] ?? $offset );
			$static_chunk  = substr( $content, $offset, max( 0, $match_offset - $offset ) );
			if ( ! $this->append_with_budget( $rendered, $static_chunk, $max_bytes ) ) {
				$overflowed = true;
				break;
			}

			$remaining   = max( 0, $max_bytes - strlen( $rendered ) );
			$replacement = $this->resolve_token(
				(string) ( $match[1][0] ?? '' ),
				$matched_token,
				$context,
				$field_values,
				$field_overflows,
				$format,
				$remaining,
				$diagnostics
			);
			if ( ! $this->append_with_budget( $rendered, $replacement['content'], $max_bytes ) || ! empty( $replacement['overflowed'] ) ) {
				$overflowed = true;
				break;
			}

			$offset = $match_offset + strlen( $matched_token );
		}

		if ( ! $overflowed && $offset < $content_length && ! $this->append_with_budget( $rendered, substr( $content, $offset ), $max_bytes ) ) {
			$overflowed = true;
		}

		$rendered = self::FORMAT_SUBJECT === $format
			? $this->sanitize_subject( $rendered )
			: $this->sanitize_html( $rendered );
		if ( strlen( $rendered ) > $max_bytes ) {
			$rendered   = $this->byte_prefix( $rendered, $max_bytes );
			$overflowed = true;
		}

		return array(
			'content'     => $rendered,
			'diagnostics' => $diagnostics,
			'overflowed'  => $overflowed,
		);
	}

	/**
	 * Resolve one supported token without allowing a single field value to create
	 * an unbounded temporary replacement.
	 *
	 * @param string                          $raw_name Token name without braces.
	 * @param string                          $raw_token Original token.
	 * @param array<string,mixed>             $context Render context.
	 * @param array<string,string>            $field_values Normalized field values.
	 * @param array<string,bool>              $field_overflows Truncated field values.
	 * @param string                          $format Output format.
	 * @param int                             $remaining_bytes Remaining output bytes.
	 * @param array<string,array<int,string>> $diagnostics Diagnostics.
	 * @return array{content:string,overflowed:bool}
	 */
	private function resolve_token( $raw_name, $raw_token, array $context, array $field_values, array $field_overflows, $format, $remaining_bytes, array &$diagnostics ) {
		$name = strtolower( $raw_name );
		if ( 0 === strpos( $name, 'field:' ) ) {
			$field_key = $this->normalize_field_key( substr( $name, 6 ) );
			$token     = '{field:' . $field_key . '}';
			if ( '' === $field_key ) {
				$this->add_diagnostic( $diagnostics, 'unknown', $raw_token );
				return array( 'content' => '', 'overflowed' => false );
			}
			if ( ! array_key_exists( $field_key, $field_values ) || '' === $field_values[ $field_key ] ) {
				$this->add_diagnostic( $diagnostics, 'missing', $token );
				return array( 'content' => '', 'overflowed' => false );
			}

			$this->add_diagnostic( $diagnostics, 'used', $token );
			$result = $this->format_value_bounded( $field_values[ $field_key ], $format, true, $remaining_bytes );
			$result['overflowed'] = ! empty( $field_overflows[ $field_key ] ) || ! empty( $result['overflowed'] );
			return $result;
		}

		$token = '{' . $name . '}';
		if ( ! in_array( $name, $this->context_tags, true ) ) {
			$this->add_diagnostic( $diagnostics, 'unknown', $raw_token );
			return array( 'content' => '', 'overflowed' => false );
		}

		$value_result = array_key_exists( $name, $context )
			? $this->string_value_bounded( $context[ $name ], self::MAX_VALUE_INPUT_BYTES )
			: array( 'content' => '', 'overflowed' => false );
		$value = $value_result['content'];
		if ( '' === $value ) {
			$this->add_diagnostic( $diagnostics, 'missing', $token );
			return array( 'content' => '', 'overflowed' => false );
		}

		$this->add_diagnostic( $diagnostics, 'used', $token );
		$result = $this->format_value_bounded( $value, $format, false, $remaining_bytes );
		$result['overflowed'] = ! empty( $value_result['overflowed'] ) || ! empty( $result['overflowed'] );
		return $result;
	}

	/**
	 * Convert supported field input shapes into one key/value map.
	 *
	 * @param array<mixed>       $fields Associative map, one row, or a list of rows.
	 * @param array<string,bool> $overflows Field truncation flags.
	 * @return array<string,string>
	 */
	private function normalize_fields( array $fields, array &$overflows ) {
		$normalized = array();
		if ( array_key_exists( 'field_key', $fields ) ) {
			$fields = array( $fields );
		}

		foreach ( $fields as $map_key => $field ) {
			if ( is_array( $field ) && array_key_exists( 'field_key', $field ) ) {
				$key = $this->normalize_field_key( $field['field_key'] );
				if ( '' === $key ) {
					continue;
				}

				$value = array_key_exists( 'field_value', $field )
					? $field['field_value']
					: ( $field['value'] ?? '' );
				$result = $this->string_value_bounded( $value, self::MAX_VALUE_INPUT_BYTES );
				$normalized[ $key ] = $result['content'];
				if ( ! empty( $result['overflowed'] ) ) {
					$overflows[ $key ] = true;
				}
				continue;
			}

			if ( ! is_string( $map_key ) ) {
				continue;
			}

			$key = $this->normalize_field_key( $map_key );
			if ( '' === $key ) {
				continue;
			}

			if ( is_array( $field ) && ( array_key_exists( 'field_value', $field ) || array_key_exists( 'value', $field ) ) ) {
				$field = array_key_exists( 'field_value', $field ) ? $field['field_value'] : $field['value'];
			}
			$result = $this->string_value_bounded( $field, self::MAX_VALUE_INPUT_BYTES );
			$normalized[ $key ] = $result['content'];
			if ( ! empty( $result['overflowed'] ) ) {
				$overflows[ $key ] = true;
			}
		}

		return $normalized;
	}

	/**
	 * Normalize a merge-tag field identifier without requiring WordPress helpers.
	 *
	 * @param mixed $key Raw key.
	 * @return string
	 */
	private function normalize_field_key( $key ) {
		$key = is_scalar( $key ) ? strtolower( (string) $key ) : '';

		return (string) preg_replace( '/[^a-z0-9_-]/', '', $key );
	}

	/**
	 * Convert scalar or list-like values to deterministic display text.
	 *
	 * @param mixed $value Raw value.
	 * @param int   $max_bytes Maximum output bytes.
	 * @param int   $depth Current recursion depth.
	 * @return array{content:string,overflowed:bool}
	 */
	private function string_value_bounded( $value, $max_bytes, $depth = 0 ) {
		$max_bytes = max( 0, (int) $max_bytes );
		if ( is_scalar( $value ) ) {
			$value = (string) $value;
			return array(
				'content'    => $this->byte_prefix( $value, $max_bytes ),
				'overflowed' => strlen( $value ) > $max_bytes,
			);
		}
		if ( ! is_array( $value ) || $depth >= 5 ) {
			return array( 'content' => '', 'overflowed' => false );
		}

		$content    = '';
		$overflowed = false;
		foreach ( array_slice( $value, 0, 100 ) as $child ) {
			$separator = '' !== $content ? ', ' : '';
			$remaining = max( 0, $max_bytes - strlen( $content ) - strlen( $separator ) );
			$result    = $this->string_value_bounded( $child, $remaining, $depth + 1 );
			if ( '' !== $result['content'] ) {
				if ( strlen( $separator ) > max( 0, $max_bytes - strlen( $content ) ) ) {
					$overflowed = true;
					break;
				}
				$content .= $separator . $result['content'];
			}
			if ( ! empty( $result['overflowed'] ) ) {
				$overflowed = true;
				break;
			}
		}

		return array( 'content' => $content, 'overflowed' => $overflowed );
	}

	/**
	 * Format one already-resolved value for its output context.
	 *
	 * @param string $value Value.
	 * @param string $format Output format.
	 * @param bool   $preserve_lines Preserve field line breaks in HTML.
	 * @return string
	 */
	private function format_value( $value, $format, $preserve_lines ) {
		if ( self::FORMAT_SUBJECT === $format ) {
			return $this->sanitize_subject( $value );
		}

		$value = $this->escape_html( $value );

		return $preserve_lines ? nl2br( $value, true ) : $value;
	}

	/**
	 * Format one replacement with a fixed input ceiling and an output hint.
	 *
	 * HTML escaping never shrinks a valid field value, so limiting its raw input
	 * to the remaining output bytes avoids a large transient allocation. Subject
	 * values need their complete bounded prefix because sanitization may collapse
	 * whitespace before the final output budget is applied.
	 *
	 * @param string $value Value.
	 * @param string $format Output format.
	 * @param bool   $preserve_lines Preserve field line breaks in HTML.
	 * @param int    $remaining_bytes Remaining output bytes.
	 * @return array{content:string,overflowed:bool}
	 */
	private function format_value_bounded( $value, $format, $preserve_lines, $remaining_bytes ) {
		$value       = (string) $value;
		$input_limit = self::MAX_VALUE_INPUT_BYTES;
		if ( self::FORMAT_HTML === $format ) {
			$input_limit = min( $input_limit, $remaining_bytes );
		}
		$overflowed = strlen( $value ) > $input_limit;
		if ( $overflowed ) {
			$value = $this->byte_prefix( $value, $input_limit );
		}

		return array(
			'content'    => $this->format_value( $value, $format, $preserve_lines ),
			'overflowed' => $overflowed,
		);
	}

	/**
	 * Append a chunk without letting the rendered buffer exceed its byte budget.
	 *
	 * @param string $buffer Rendered buffer.
	 * @param string $chunk Chunk.
	 * @param int    $max_bytes Maximum bytes.
	 * @return bool True when the complete chunk fit.
	 */
	private function append_with_budget( &$buffer, $chunk, $max_bytes ) {
		$chunk = (string) $chunk;
		$remaining = max( 0, $max_bytes - strlen( $buffer ) );
		if ( strlen( $chunk ) <= $remaining ) {
			$buffer .= $chunk;
			return true;
		}

		if ( $remaining > 0 ) {
			$buffer .= $this->byte_prefix( $chunk, $remaining );
		}

		return false;
	}

	/**
	 * Return a valid UTF-8 prefix no longer than the requested byte count.
	 *
	 * @param string $value Value.
	 * @param int    $max_bytes Maximum bytes.
	 * @return string
	 */
	private function byte_prefix( $value, $max_bytes ) {
		$value     = (string) $value;
		$max_bytes = max( 0, (int) $max_bytes );
		if ( 0 === $max_bytes || '' === $value ) {
			return '';
		}
		if ( strlen( $value ) <= $max_bytes ) {
			return $value;
		}
		if ( function_exists( 'mb_strcut' ) ) {
			return (string) mb_strcut( $value, 0, $max_bytes, 'UTF-8' );
		}

		$prefix = substr( $value, 0, $max_bytes );
		while ( '' !== $prefix && 1 !== preg_match( '//u', $prefix ) ) {
			$prefix = substr( $prefix, 0, -1 );
		}

		return $prefix;
	}

	/**
	 * Escape a merge value for HTML without making the renderer test-dependent.
	 *
	 * @param string $value Value.
	 * @return string
	 */
	private function escape_html( $value ) {
		if ( function_exists( 'esc_html' ) ) {
			return esc_html( (string) $value );
		}

		return htmlspecialchars( (string) $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' );
	}

	/**
	 * Strip markup, line breaks, and control characters from a mail subject.
	 *
	 * @param string $subject Subject.
	 * @return string
	 */
	private function sanitize_subject( $subject ) {
		if ( function_exists( 'wp_strip_all_tags' ) ) {
			$subject = wp_strip_all_tags( (string) $subject, true );
		} else {
			$subject = preg_replace( '/<[^>]*>/', '', (string) $subject );
		}
		$subject = preg_replace( '/[\r\n\t]+/', ' ', $subject );
		$subject = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', (string) $subject );
		$subject = preg_replace( '/ {2,}/', ' ', (string) $subject );
		$subject = trim( (string) $subject );

		if ( function_exists( 'sanitize_text_field' ) ) {
			$subject = sanitize_text_field( $subject );
			$subject = str_replace( array( "\r", "\n" ), ' ', (string) $subject );
		}

		return trim( (string) $subject );
	}

	/**
	 * Remove executable template syntax before visitor values are inserted.
	 *
	 * @param string $html Template HTML.
	 * @return string
	 */
	private function remove_executable_template_syntax( $html ) {
		$html = preg_replace( '/<\?(?:php|=)?[\s\S]*?\?>/i', '', (string) $html );
		$html = is_string( $html ) ? $html : '';

		if ( function_exists( 'strip_shortcodes' ) ) {
			$html = (string) strip_shortcodes( $html );
		}

		// Remove any unregistered shortcode-like tags left by strip_shortcodes().
		$html = preg_replace( '/\[(?:\/)?[A-Za-z][A-Za-z0-9_-]*(?:\s[^\]\r\n]*)?\]/', '', $html );

		return is_string( $html ) ? $html : '';
	}

	/**
	 * Apply the plugin email allowlist, with a conservative standalone fallback.
	 *
	 * @param string $html Rendered HTML.
	 * @return string
	 */
	private function sanitize_html( $html ) {
		$repository = __NAMESPACE__ . '\\AutoReplyTemplateRepository';
		if ( class_exists( $repository ) && method_exists( $repository, 'allowed_email_html' ) && function_exists( 'wp_kses' ) ) {
			$html = wp_kses(
				$html,
				call_user_func( array( $repository, 'allowed_email_html' ) ),
				array( 'http', 'https', 'mailto', 'tel' )
			);
			if ( method_exists( $repository, 'sanitize_svg_paint_attributes' ) ) {
				$html = call_user_func( array( $repository, 'sanitize_svg_paint_attributes' ), $html );
			}

			return (string) $html;
		}

		if ( function_exists( 'wp_kses_post' ) ) {
			return (string) wp_kses_post( $html );
		}

		$html = preg_replace( '/<\s*(script|iframe|object|embed|form|base|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/i', '', (string) $html );
		$html = preg_replace( '/<\s*\/?\s*(script|iframe|object|embed|form|base|style)\b[^>]*>/i', '', (string) $html );
		$html = preg_replace( '/\s+on[a-z][a-z0-9:_-]*\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', (string) $html );
		$html = preg_replace( '/\s+(href|src|xlink:href)\s*=\s*(["\'])\s*(?:javascript|data)\s*:[\s\S]*?\2/i', '', (string) $html );

		return is_string( $html ) ? $html : '';
	}

	/**
	 * Add one unique diagnostic tag.
	 *
	 * @param array<string,array<int,string>> $diagnostics Diagnostics.
	 * @param string                          $bucket Diagnostic bucket.
	 * @param string                          $token Tag token.
	 * @return void
	 */
	private function add_diagnostic( array &$diagnostics, $bucket, $token ) {
		if ( ! isset( $diagnostics[ $bucket ] ) || in_array( $token, $diagnostics[ $bucket ], true ) ) {
			return;
		}

		$diagnostics[ $bucket ][] = $token;
	}
}
