<?php
/**
 * Mailer message preparation helpers.
 *
 * @package MailDoctor
 */

namespace MailDoctor;

use DateTimeImmutable;
use DateTimeZone;
use WP_Error;

trait Mailer_Message_Trait {

	/**
	 * Normalize wp_mail arguments into a consistent structure.
	 *
	 * @param mixed $atts Raw arguments.
	 * @return array<string, mixed>
	 */
	protected static function normalize_mail_args( $atts ): array {
		$defaults = array(
			'to'          => '',
			'subject'     => '',
			'message'     => '',
			'headers'     => array(),
			'attachments' => array(),
		);

		$args = wp_parse_args( is_array( $atts ) ? $atts : array(), $defaults );

		$args['headers'] = Logger::normalize_headers( $args['headers'] );

		if ( ! is_array( $args['attachments'] ) ) {
			if ( empty( $args['attachments'] ) ) {
				$args['attachments'] = array();
			} else {
				$args['attachments'] = array_map( 'trim', explode( ',', (string) $args['attachments'] ) );
			}
		}

		$args['attachments'] = array_values(
			array_filter(
				array_map(
					static function ( $attachment ) {
						$value = trim( (string) $attachment );
						return '' === $value ? null : $value;
					},
					(array) $args['attachments']
				)
			)
		);

		return $args;
	}

	/**
	 * Generate deterministic idempotency key based on message inputs.
	 *
	 * @param array<string, mixed> $args Normalized mail arguments.
	 * @return string|null
	 */
	protected static function generate_idempotency_key( array $args ): ?string {
		if ( null === self::$idempotency_bucket ) {
			return null;
		}

		$recipients = self::normalize_recipient_list( $args['to'] );
		$subject    = strtolower( trim( (string) ( $args['subject'] ?? '' ) ) );
		$message    = (string) ( $args['message'] ?? '' );

		$attachments = array_filter(
			array_map(
				static function ( $attachment ) {
					return (string) $attachment;
				},
				(array) ( $args['attachments'] ?? array() )
			)
		);
		sort( $attachments, SORT_STRING );

		$headers = self::normalize_headers_for_hash( $args['headers'] ?? array() );

		$components = array(
			self::$idempotency_bucket,
			self::$current_event ?: 'wp_mail',
			$recipients,
			$subject,
			hash( 'sha1', $message ),
			hash( 'sha1', implode( ',', $attachments ) ),
			hash( 'sha1', wp_json_encode( $headers ) ),
		);

		return hash( 'sha256', implode( '|', $components ) );
	}

	/**
	 * Normalize recipients into deterministic hash string.
	 *
	 * @param mixed $to Recipient payload.
	 * @return string
	 */
	protected static function normalize_recipient_list( $to ): string {
		$recipients = self::parse_recipient_array( $to );

		if ( empty( $recipients ) ) {
			return '';
		}

		sort( $recipients, SORT_STRING );

		return implode( ',', $recipients );
	}

	/**
	 * Convert recipient payload into sanitized list.
	 *
	 * @param mixed $to Recipient payload.
	 * @return array<int, string>
	 */
	protected static function parse_recipient_array( $to ): array {
		if ( empty( $to ) ) {
			return array();
		}

		if ( ! is_array( $to ) ) {
			$to = array_map( 'trim', explode( ',', (string) $to ) );
		}

		$sanitized = array();

		foreach ( $to as $recipient ) {
			$email = sanitize_email( $recipient );
			if ( $email && is_email( $email ) ) {
				$sanitized[] = strtolower( $email );
			}
		}

		return array_values( array_unique( $sanitized ) );
	}

	/**
	 * Inspect attachments against policy limits.
	 *
	 * @param array<int, mixed> $attachments Attachment payload.
	 * @return array{
	 *     meta: array<int, array<string, mixed>>,
	 *     total: int,
	 *     error?: WP_Error
	 * }
	 */
	protected static function inspect_attachments( array $attachments ): array {
		$policy = Settings::attachment_policy();

		$total      = 0;
		$meta       = array();
		$blocked    = array();
		$max_size   = isset( $policy['max_total_bytes'] ) ? (int) $policy['max_total_bytes'] : ( 10 * MB_IN_BYTES );
		$disallowed = isset( $policy['blocked_mimes'] ) && is_array( $policy['blocked_mimes'] ) ? array_map( 'strtolower', $policy['blocked_mimes'] ) : array();

		if ( empty( $attachments ) ) {
			return array(
				'meta'  => array(),
				'total' => 0,
			);
		}

		foreach ( $attachments as $attachment ) {
			$item_meta = array(
				'name'     => '',
				'mime'     => '',
				'size'     => 0,
				'checksum' => '',
			);

			if ( is_array( $attachment ) ) {
				$name     = isset( $attachment['name'] ) ? (string) $attachment['name'] : __( 'Inline Attachment', 'mail-doctor' );
				$mime     = isset( $attachment['type'] ) ? strtolower( (string) $attachment['type'] ) : '';
				$data     = isset( $attachment['data'] ) ? (string) $attachment['data'] : '';
				$size     = strlen( $data );
				$checksum = $data ? hash( 'sha256', $data ) : '';

				$item_meta['name']     = $name;
				$item_meta['mime']     = $mime;
				$item_meta['size']     = $size;
				$item_meta['checksum'] = $checksum;

				$total += $size;

				if ( $mime && in_array( $mime, $disallowed, true ) ) {
					$blocked[] = $name;
				}
			} else {
				$path = wp_normalize_path( (string) $attachment );
				$name = basename( $path );

				if ( '' === $path || ! file_exists( $path ) || ! is_readable( $path ) ) {
					return array(
						'meta'  => $meta,
						'total' => $total,
						'error' => new WP_Error(
							'mail_doctor_attachment_missing',
							sprintf(
								/* translators: %s: attachment path */
								__( 'Attachment %s is missing or unreadable.', 'mail-doctor' ),
								$name ?: $path
							)
						),
					);
				}

				$size = (int) filesize( $path );
				$mime = '';

				$filetype = wp_check_filetype( $name );
				if ( ! empty( $filetype['type'] ) ) {
					$mime = strtolower( $filetype['type'] );
				} elseif ( function_exists( 'mime_content_type' ) ) {
					$detected = @mime_content_type( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					if ( $detected ) {
						$mime = strtolower( $detected );
					}
				}

				$total   += $size;
				$checksum = hash_file( 'sha256', $path );

				$item_meta['name']     = $name ?: $path;
				$item_meta['mime']     = $mime;
				$item_meta['size']     = $size;
				$item_meta['checksum'] = $checksum ?: '';

				if ( $mime && in_array( $mime, $disallowed, true ) ) {
					$blocked[] = $name ?: $path;
				}
			}

			$meta[] = $item_meta;
		}

		if ( $blocked ) {
			return array(
				'meta'  => $meta,
				'total' => $total,
				'error' => new WP_Error(
					'mail_doctor_attachment_blocked',
					sprintf(
						/* translators: %s: comma-separated list of blocked attachments */
						__( 'Attachments blocked by policy: %s.', 'mail-doctor' ),
						implode( ', ', $blocked )
					)
				),
			);
		}

		if ( $max_size > 0 && $total > $max_size ) {
			return array(
				'meta'  => $meta,
				'total' => $total,
				'error' => new WP_Error(
					'mail_doctor_attachment_oversize',
					sprintf(
						/* translators: 1: formatted total size, 2: allowed size in MB */
						__( 'Total attachment size %1$s exceeds the configured limit of %2$d MB.', 'mail-doctor' ),
						size_format( $total ),
						isset( $policy['max_total_mb'] ) ? (int) $policy['max_total_mb'] : ( $max_size / MB_IN_BYTES )
					)
				),
			);
		}

		return array(
			'meta'  => $meta,
			'total' => $total,
		);
	}

	/**
	 * Handle attachment policy violations by logging failure.
	 *
	 * @param array<string, mixed>             $mail_args Mail arguments.
	 * @param WP_Error                         $error     Error details.
	 * @param array<int, array<string, mixed>> $metadata Attachment metadata.
	 * @return void
	 */
	protected static function handle_attachment_violation( array $mail_args, WP_Error $error, array $metadata ): void {
		$context = self::build_logging_context();

		$log_id = Logger::log_attempt( $mail_args, $context, 'failed' );

		if ( $log_id ) {
			Logger::annotate_attachments( $log_id, $metadata, array_sum( \wp_list_pluck( $metadata, 'size' ) ) );
			Logger::mark_failed( $log_id, $error );
		}

		self::cleanup_context();
	}

	/**
	 * Record suppression skip and annotate logs.
	 *
	 * @param array<string, mixed>  $mail_args Normalized arguments.
	 * @param array<string, string> $suppressed Map of email => reason.
	 * @return void
	 */
	protected static function handle_suppressed_delivery( array $mail_args, array $suppressed ): void {
		self::prepare_profile();

		$context               = self::build_logging_context();
		$context['suppressed'] = array_keys( $suppressed );

		$log_id = Logger::log_attempt( $mail_args, $context, 'skipped' );

		if ( $log_id ) {
			Logger::mark_skipped( $log_id, $suppressed );
		}
	}

	/**
	 * Normalize headers for inclusion in idempotency hashing.
	 *
	 * @param array<int, string> $headers Headers array.
	 * @return array<int, string>
	 */
	protected static function normalize_headers_for_hash( $headers ): array {
		$normalized = Logger::normalize_headers( $headers );

		if ( empty( $normalized ) ) {
			return array();
		}

		sort( $normalized, SORT_STRING );

		return $normalized;
	}

	/**
	 * Inject tracking headers into outgoing email.
	 *
	 * @param mixed $headers Original headers.
	 * @param int   $log_id Log identifier.
	 * @return array<int, string>
	 */
	protected static function inject_metadata_headers( $headers, int $log_id ): array {
		$headers     = Logger::normalize_headers( $headers );
		$headers     = array_filter(
			$headers,
			static function ( $header ) {
				return stripos( $header, 'X-Order-Mail-Doctor-ID:' ) !== 0;
			}
		);
		$headers[]   = 'X-Order-Mail-Doctor-ID: ' . $log_id;
		$correlation = self::correlation_header_value();
		if ( '' !== $correlation ) {
			$headers[] = 'X-Order-Mail-Doctor-Correlation: ' . $correlation;
		}

		return $headers;
	}

	/**
	 * Inject deterministic Message-ID and threading headers.
	 *
	 * @param array<int, string>   $headers Original headers.
	 * @param array<string, mixed> $context Context data.
	 * @return array<int, string>
	 */
	protected static function inject_thread_headers( array $headers, array $context ): array {
		$headers = array_filter(
			$headers,
			static function ( $header ) {
				return stripos( $header, 'Message-ID:' ) !== 0 && stripos( $header, 'In-Reply-To:' ) !== 0 && stripos( $header, 'References:' ) !== 0;
			}
		);

		$thread = self::build_thread_identifiers( $context );

		self::$thread_headers = array(
			'message_id'  => self::normalize_message_id( $thread['message_id'] ),
			'in_reply_to' => self::normalize_message_id( $thread['in_reply_to'] ),
		);

		return $headers;
	}

	/**
	 * Maybe add List-Unsubscribe headers.
	 *
	 * @param array<int, string>   $headers Existing headers.
	 * @param array<string, mixed> $settings Plugin settings.
	 * @param array<string, mixed> $context Mail context.
	 * @return array<int, string>
	 */
	protected static function maybe_add_unsubscribe_headers( array $headers, array $settings, array $context ): array {
		if ( empty( $settings['list_unsubscribe'] ) || ( isset( $context['event'] ) && self::is_critical_event( $context['event'] ) ) ) {
			return $headers;
		}

		$mailto = $settings['list_unsubscribe_mailto'] ?? '';
		$url    = $settings['list_unsubscribe_url'] ?? '';

		if ( empty( $mailto ) && empty( $url ) ) {
			return $headers;
		}

		$parts = array();

		if ( ! empty( $mailto ) ) {
			$parts[] = '<mailto:' . sanitize_email( $mailto ) . '>';
		}

		if ( ! empty( $url ) ) {
			$parts[] = '<' . esc_url_raw( $url ) . '>';
		}

		if ( empty( $parts ) ) {
			return $headers;
		}

		$headers = array_filter(
			$headers,
			static function ( $header ) {
				return stripos( $header, 'List-Unsubscribe:' ) !== 0;
			}
		);

		$headers[] = 'List-Unsubscribe: ' . implode( ', ', $parts );
		if ( ! empty( $settings['list_unsubscribe_url'] ) ) {
			$headers[] = 'List-Unsubscribe-Post: List-Unsubscribe=One-Click';
		}

		return $headers;
	}

	/**
	 * Build deterministic Message-ID / threading identifiers.
	 *
	 * @param array<string, mixed> $context Logging context.
	 * @return array{message_id: string|null, in_reply_to: string|null}
	 */
	protected static function build_thread_identifiers( array $context ): array {
		$event    = isset( $context['event'] ) ? (string) $context['event'] : '';
		$order_id = isset( $context['order_id'] ) ? (int) $context['order_id'] : 0;

		$domain = wp_parse_url( home_url(), PHP_URL_HOST );
		if ( empty( $domain ) ) {
			$domain = 'example.com';
		}

		if ( strpos( $event, 'woocommerce_' ) === 0 && $order_id > 0 ) {
			$normalized = sanitize_title_with_dashes( $event );
			$base       = sprintf( 'order-%d-%s@%s', $order_id, $normalized, $domain );
			$root       = sprintf( 'order-%d@%s', $order_id, $domain );

			$is_update  = ( false !== strpos( $event, 'status' ) || false !== strpos( $event, 'customer_note' ) );
			$is_preview = ( false !== strpos( $event, 'preview' ) );

			return array(
				'message_id'  => $base,
				'in_reply_to' => ( $is_update || $is_preview ) ? $root : null,
			);
		}

		$message_id = apply_filters(
			'mail_doctor_message_id',
			sprintf( '%s@%s', uniqid( 'maildoctor-', true ), $domain ),
			$context
		);

		return array(
			'message_id'  => $message_id,
			'in_reply_to' => null,
		);
	}

	/**
	 * Normalize message-id header.
	 *
	 * @param string|null $value Raw message id.
	 * @return string|null
	 */
	protected static function normalize_message_id( ?string $value ): ?string {
		if ( empty( $value ) ) {
			return null;
		}

		$value = trim( (string) $value );

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

		if ( substr( $value, 0, 1 ) !== '<' ) {
			$value = '<' . $value;
		}

		if ( substr( $value, -1 ) !== '>' ) {
			$value .= '>';
		}

		return $value;
	}

	/**
	 * Determine if an event is critical (unsubscribe header should not be added).
	 *
	 * @param string $event Event slug.
	 * @return bool
	 */
	protected static function is_critical_event( string $event ): bool {
		$keywords = array(
			'password',
			'reset',
			'failed-order',
			'low-stock',
			'new-order',
			'processing-order',
			'on-hold-order',
		);

		foreach ( $keywords as $keyword ) {
			if ( false !== strpos( $event, $keyword ) ) {
				return true;
			}
		}

		return false;
	}
}
