<?php
/**
 * wp_mail() delivery through the Brevo transactional API.
 *
 * @package RewindBrevoFormBridge
 */

namespace RewindBrevoFormBridge\Emails;

use RewindBrevoFormBridge\Admin\Settings;
use RewindBrevoFormBridge\Api\Response;
use RewindBrevoFormBridge\Api\Transactional;
use RewindBrevoFormBridge\Support\Errors;

defined( 'ABSPATH' ) || exit;

/**
 * Routes every wp_mail() call through Brevo — the replacement for a
 * dedicated WP SMTP plugin.
 *
 * Intercepts with the pre_wp_mail short-circuit (WP 5.7+) rather than
 * PHPMailer, so delivery rides HTTPS 443 and needs no open SMTP port.
 * When the feature is off, the transport is set to SMTP relay, no API key
 * is saved, or a send fails with fallback left on, the filter returns null
 * and WordPress falls through to its default mailer: site email never
 * silently stops because of this plugin.
 *
 * @since 0.5.0
 */
class Mailer {

	/**
	 * Settings storage service.
	 *
	 * @var Settings
	 */
	private Settings $settings;

	/**
	 * Transactional email resource.
	 *
	 * @var Transactional
	 */
	private Transactional $transactional;

	/**
	 * Constructor.
	 *
	 * @since 0.5.0
	 * @param Settings      $settings      Settings storage service.
	 * @param Transactional $transactional Transactional email resource.
	 */
	public function __construct( Settings $settings, Transactional $transactional ) {
		$this->settings      = $settings;
		$this->transactional = $transactional;
	}

	/**
	 * Attach the wp_mail short-circuit.
	 *
	 * @since 0.5.0
	 * @return void
	 */
	public function register_hooks(): void {
		add_filter( 'pre_wp_mail', array( $this, 'intercept' ), 10, 2 );
	}

	/**
	 * Whether API delivery is configured and switched on.
	 *
	 * @since 0.5.0
	 * @return bool
	 */
	public function is_active(): bool {
		return (bool) $this->settings->get( 'email_enabled', false )
			&& 'api' === $this->settings->get( 'email_transport', 'api' )
			&& '' !== $this->settings->api_key();
	}

	/**
	 * pre_wp_mail callback.
	 *
	 * @since 0.5.0
	 * @param bool|null $short_circuit Value from an earlier filter.
	 * @param array     $atts          wp_mail arguments: to, subject, message,
	 *                                 headers, attachments.
	 * @return bool|null True when Brevo accepted the send, false on a final
	 *                   failure, null to let the default mailer run.
	 */
	public function intercept( $short_circuit, $atts ) {
		if ( null !== $short_circuit || ! is_array( $atts ) ) {
			return $short_circuit;
		}

		if ( ! $this->is_active() ) {
			return null;
		}

		$payload = $this->build_payload( $atts );

		if ( is_wp_error( $payload ) ) {
			return $this->failed( $payload );
		}

		$response = $this->transactional->send( $payload );

		if ( ! $response->is_success() ) {
			return $this->failed(
				new \WP_Error( 'wp_mail_failed', Errors::for_email( $response ), $atts )
			);
		}

		MailStatus::record_success();

		/**
		 * Fires after Brevo accepted a wp_mail() send.
		 *
		 * @since 0.5.0
		 * @param array    $payload  The /smtp/email request body. No API key inside.
		 * @param Response $response Brevo's response; the body carries messageId.
		 */
		do_action( 'rbfb_mail_sent', $payload, $response );

		return true;
	}

	/**
	 * Send the explicit test email from Settings → Email.
	 *
	 * @since 0.5.0
	 * @param string $to Recipient address.
	 * @return Response
	 */
	public function send_test( string $to ): Response {
		$payload = array(
			'sender'      => $this->sender( array() ),
			'to'          => array( array( 'email' => $to ) ),
			'subject'     => __( 'Rewind Form Bridge for Brevo — test email', 'rewind-form-bridge-for-brevo' ),
			'htmlContent' => '<p>' . esc_html__(
				'This is a test email sent through the Brevo transactional API. If you are reading it, WordPress email from this site delivers through Brevo — no SMTP plugin required.',
				'rewind-form-bridge-for-brevo'
			) . '</p>',
		);

		$response = $this->transactional->send( $payload );

		if ( $response->is_success() ) {
			MailStatus::record_success();
		} else {
			MailStatus::record_failure( Errors::for_email( $response ) );
		}

		return $response;
	}

	/**
	 * Handle a failed send: fall back to the default mailer when the owner
	 * left the fallback on (the defensive default), otherwise fail the
	 * wp_mail() call exactly the way PHPMailer would.
	 *
	 * @since 0.5.0
	 * @param \WP_Error $error Send error.
	 * @return bool|null
	 */
	private function failed( \WP_Error $error ) {
		MailStatus::record_failure( $error->get_error_message() );

		if ( (bool) $this->settings->get( 'email_fallback', true ) ) {
			return null;
		}

		/*
		 * This class stands in for wp_mail()'s delivery step, so it has to
		 * fire core's own hooks rather than prefixed ones — anything else
		 * would silently break every plugin listening for mail failures.
		 */
		// Same contract as wp_mail(): announce the failure, return false.
		do_action( 'wp_mail_failed', $error ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

		return false;
	}

	/**
	 * Convert wp_mail() arguments into a Brevo /smtp/email payload.
	 *
	 * @since 0.5.0
	 * @param array $atts wp_mail arguments.
	 * @return array|\WP_Error
	 */
	private function build_payload( array $atts ) {
		$to = $this->address_list( isset( $atts['to'] ) ? $atts['to'] : array() );

		if ( 0 === count( $to ) ) {
			return new \WP_Error( 'wp_mail_failed', __( 'No valid recipient address.', 'rewind-form-bridge-for-brevo' ), $atts );
		}

		$headers = $this->parse_headers( isset( $atts['headers'] ) ? $atts['headers'] : '' );

		$payload = array(
			'sender'  => $this->sender( $headers['from'] ),
			'to'      => $to,
			'subject' => isset( $atts['subject'] ) ? (string) $atts['subject'] : '',
		);

		$message = isset( $atts['message'] ) ? (string) $atts['message'] : '';
		// Core's own filter, deliberately: see failed() above.
		$type    = apply_filters( 'wp_mail_content_type', '' !== $headers['content_type'] ? $headers['content_type'] : 'text/plain' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

		if ( false !== strpos( (string) $type, 'text/html' ) ) {
			$payload['htmlContent'] = $message;
		} else {
			$payload['textContent'] = $message;
		}

		foreach ( array( 'cc', 'bcc' ) as $copy ) {
			if ( count( $headers[ $copy ] ) > 0 ) {
				$payload[ $copy ] = $headers[ $copy ];
			}
		}

		$reply_to = $this->reply_to( $headers['reply_to'] );

		if ( array() !== $reply_to ) {
			$payload['replyTo'] = $reply_to;
		}

		if ( count( $headers['custom'] ) > 0 ) {
			$payload['headers'] = $headers['custom'];
		}

		$attachments = $this->attachments( isset( $atts['attachments'] ) ? $atts['attachments'] : array() );

		if ( count( $attachments ) > 0 ) {
			$payload['attachment'] = $attachments;
		}

		/**
		 * Filter the Brevo payload built from a wp_mail() call.
		 *
		 * @since 0.5.0
		 * @param array $payload The /smtp/email request body.
		 * @param array $atts    The original wp_mail() arguments.
		 */
		return apply_filters( 'rbfb_mail_payload', $payload, $atts );
	}

	/**
	 * Resolve the sender.
	 *
	 * Brevo rejects a send whose From address is not a registered sender,
	 * so the address saved in Settings → Email normally wins: it is the one
	 * the owner picked from their verified senders. Turning off "always use
	 * this sender" lets a mail's own From header through instead, with the
	 * saved address as the fallback.
	 *
	 * @since 0.5.0
	 * @param array $from_header ['email' => ?, 'name' => ?] from the headers.
	 * @return array{email: string, name?: string}
	 */
	private function sender( array $from_header ): array {
		// Replicates wp_mail()'s own default: wordpress@ + site host, www. stripped.
		$sitename = (string) wp_parse_url( network_home_url(), PHP_URL_HOST );

		if ( 'www.' === substr( $sitename, 0, 4 ) ) {
			$sitename = substr( $sitename, 4 );
		}

		// Core's own filters, deliberately: see failed() above.
		$email = apply_filters( 'wp_mail_from', 'wordpress@' . $sitename ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
		$name  = apply_filters( 'wp_mail_from_name', 'WordPress' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

		$saved_email = (string) $this->settings->get( 'email_from_email', '' );
		$saved_name  = (string) $this->settings->get( 'email_from_name', '' );
		$force       = (bool) $this->settings->get( 'email_force_from', true );

		$has_header = ! empty( $from_header['email'] ) && is_email( $from_header['email'] );

		if ( $has_header && ! $force ) {
			$email = (string) $from_header['email'];
			$name  = empty( $from_header['name'] ) ? $name : (string) $from_header['name'];
		} elseif ( is_email( $saved_email ) ) {
			$email = $saved_email;
			$name  = '' !== $saved_name ? $saved_name : $name;
		} elseif ( $has_header ) {
			// Nothing saved to force, so the header is still better than the
			// wordpress@ default, which is almost never a verified sender.
			$email = (string) $from_header['email'];
			$name  = empty( $from_header['name'] ) ? $name : (string) $from_header['name'];
		}

		$sender = array( 'email' => $email );

		if ( '' !== (string) $name ) {
			$sender['name'] = (string) $name;
		}

		return $sender;
	}

	/**
	 * The reply-to address: the header wins, else the saved default.
	 *
	 * @since 0.5.0
	 * @param array $header_reply_to Addresses parsed from the Reply-To header.
	 * @return array Empty array when there is nothing to send.
	 */
	private function reply_to( array $header_reply_to ): array {
		if ( count( $header_reply_to ) > 0 ) {
			return $header_reply_to[0];
		}

		$saved = (string) $this->settings->get( 'email_reply_to', '' );

		return is_email( $saved ) ? array( 'email' => $saved ) : array();
	}

	/**
	 * Parse wp_mail() headers (string or array) into structured parts.
	 *
	 * @since 0.5.0
	 * @param string|array $headers Raw headers.
	 * @return array{from: array, reply_to: array, cc: array, bcc: array, content_type: string, custom: array}
	 */
	private function parse_headers( $headers ): array {
		$parsed = array(
			'from'         => array(),
			'reply_to'     => array(),
			'cc'           => array(),
			'bcc'          => array(),
			'content_type' => '',
			'custom'       => array(),
		);

		if ( is_string( $headers ) ) {
			$headers = preg_split( '/\r\n|\r|\n/', $headers );
		}

		if ( ! is_array( $headers ) ) {
			return $parsed;
		}

		foreach ( $headers as $header ) {
			if ( ! is_string( $header ) || false === strpos( $header, ':' ) ) {
				continue;
			}

			list( $name, $value ) = explode( ':', $header, 2 );

			$name  = strtolower( trim( $name ) );
			$value = trim( $value );

			switch ( $name ) {
				case 'from':
					$from           = $this->address_list( $value );
					$parsed['from'] = 0 === count( $from ) ? array() : $from[0];
					break;
				case 'reply-to':
					$parsed['reply_to'] = array_merge( $parsed['reply_to'], $this->address_list( $value ) );
					break;
				case 'cc':
				case 'bcc':
					$parsed[ $name ] = array_merge( $parsed[ $name ], $this->address_list( $value ) );
					break;
				case 'content-type':
					// Keep the mime type only; charset and boundary mean
					// nothing to a JSON API.
					$parts                  = explode( ';', $value );
					$parsed['content_type'] = strtolower( trim( $parts[0] ) );
					break;
				case 'mime-version':
					break;
				default:
					$parsed['custom'][ $name ] = $value;
			}
		}

		return $parsed;
	}

	/**
	 * Normalise recipients ("Name <a@b.c>, d@e.f", or arrays of those) into
	 * Brevo address objects. Invalid addresses are dropped.
	 *
	 * @since 0.5.0
	 * @param string|array $value Raw address list.
	 * @return array<int, array{email: string, name?: string}>
	 */
	private function address_list( $value ): array {
		if ( is_string( $value ) ) {
			$value = explode( ',', $value );
		}

		if ( ! is_array( $value ) ) {
			return array();
		}

		$list = array();

		foreach ( $value as $item ) {
			if ( ! is_string( $item ) || '' === trim( $item ) ) {
				continue;
			}

			$name  = '';
			$email = trim( $item );

			if ( preg_match( '/^(.*)<(.+)>$/', $email, $matches ) ) {
				$name  = trim( trim( $matches[1] ), '"\'' );
				$email = trim( $matches[2] );
			}

			if ( ! is_email( $email ) ) {
				continue;
			}

			$address = array( 'email' => $email );

			if ( '' !== $name ) {
				$address['name'] = $name;
			}

			$list[] = $address;
		}

		return $list;
	}

	/**
	 * Convert wp_mail() attachments into Brevo base64 attachment objects.
	 *
	 * String array keys are treated as filenames (the WP 5.6+ convention).
	 * An unreadable file is skipped rather than failing the whole send.
	 *
	 * @since 0.5.0
	 * @param string|array $attachments Raw attachment paths.
	 * @return array<int, array{name: string, content: string}>
	 */
	private function attachments( $attachments ): array {
		if ( is_string( $attachments ) ) {
			$attachments = '' === $attachments ? array() : preg_split( '/\r\n|\r|\n/', $attachments );
		}

		if ( ! is_array( $attachments ) ) {
			return array();
		}

		$list = array();

		foreach ( $attachments as $filename => $path ) {
			if ( ! is_string( $path ) || ! is_file( $path ) || ! is_readable( $path ) ) {
				continue;
			}

			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local attachment the caller already resolved; WP_Filesystem would need credentials on some setups.
			$content = file_get_contents( $path );

			if ( false === $content ) {
				continue;
			}

			$list[] = array(
				'name'    => is_string( $filename ) ? $filename : wp_basename( $path ),
				'content' => base64_encode( $content ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Brevo's attachment field is base64 by specification.
			);
		}

		return $list;
	}
}
