<?php
/**
 * WordPress mail logger.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\MailLog;

use SUBIN\Forms\FormSubmissionContext;
use SUBIN\Leads\LeadRepository;
use SUBIN\Mail\LeadEmailStatusService;

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

if ( ! class_exists( MailErrorSanitizer::class ) ) {
	require_once __DIR__ . '/MailErrorSanitizer.php';
}

/**
 * Logs wp_mail events and links them to current lead context.
 */
class MailLogger {
	/**
	 * Event repository.
	 *
	 * @var MailEventRepository
	 */
	private $events;

	/**
	 * Lead repository.
	 *
	 * @var LeadRepository
	 */
	private $leads;

	/**
	 * Form submission context.
	 *
	 * @var FormSubmissionContext
	 */
	private $context;

	/**
	 * Settings repository.
	 *
	 * @var \SUBIN_SettingsRepository
	 */
	private $settings;

	/**
	 * Lead email status service.
	 *
	 * @var LeadEmailStatusService
	 */
	private $lead_email_status;

	/**
	 * Pending event frames for the current request.
	 *
	 * Each frame keeps only an event id and a one-way fingerprint. The final
	 * message body is never retained in memory beyond the normal wp_mail call.
	 *
	 * @var array<int,array{id:int,fingerprint:string}>
	 */
	private $pending_events = array();

	/**
	 * Constructor.
	 *
	 * @param MailEventRepository   $events Event repository.
	 * @param LeadRepository        $leads Lead repository.
	 * @param FormSubmissionContext   $context Submission context.
	 * @param \SUBIN_SettingsRepository $settings Settings repository.
	 */
	public function __construct(
		MailEventRepository $events,
		LeadRepository $leads,
		FormSubmissionContext $context,
		\SUBIN_SettingsRepository $settings
	) {
		$this->events   = $events;
		$this->leads    = $leads;
		$this->context  = $context;
		$this->settings = $settings;
		$this->lead_email_status = new LeadEmailStatusService( $leads, $events );
	}

	/**
	 * Register hooks.
	 *
	 * @return void
	 */
	public function register() {
		// Capture the final payload exposed by WordPress after other wp_mail filters.
		add_filter( 'wp_mail', array( $this, 'log_pending' ), PHP_INT_MAX );
		// A non-null pre_wp_mail result skips wp_mail and its terminal hooks entirely.
		add_filter( 'pre_wp_mail', array( $this, 'observe_short_circuit' ), PHP_INT_MAX, 2 );
		add_action( 'wp_mail_succeeded', array( $this, 'mark_succeeded' ) );
		add_action( 'wp_mail_failed', array( $this, 'mark_failed' ) );
		add_action( 'shutdown', array( $this, 'finalize_unresolved_events' ), PHP_INT_MAX );
	}

	/**
	 * Create a pending event before WordPress sends mail.
	 *
	 * @param array<string,mixed> $mail_data Mail data.
	 * @return array<string,mixed>
	 */
	public function log_pending( $mail_data ) {
		$fingerprint = $this->mail_fingerprint( $mail_data );
		if ( $this->context->should_suppress_mail_logging() ) {
			$this->pending_events[] = array(
				'id'          => 0,
				'fingerprint' => $fingerprint,
			);
			return $mail_data;
		}

		$event_id = $this->create_pending_event( $mail_data );
		$this->pending_events[] = array(
			'id'          => $event_id > 0 ? $event_id : 0,
			'fingerprint' => $fingerprint,
		);

		return $mail_data;
	}

	/**
	 * Mark latest pending event as sent.
	 *
	 * @param array<string,mixed> $mail_data Mail data.
	 * @return void
	 */
	public function mark_succeeded( $mail_data ) {
		$event = $this->take_pending_event( is_array( $mail_data ) ? $mail_data : array() );
		if ( ! $event ) {
			return;
		}

		$this->events->mark_sent( $event->id );

		if ( ! empty( $event->lead_id ) ) {
			$this->update_lead_after_event( absint( $event->lead_id ), sanitize_key( $event->source ?? '' ), LeadRepository::EMAIL_STATUS_SENT );
		}
	}

	/**
	 * Mark latest pending event as failed.
	 *
	 * @param \WP_Error $wp_error Mail error.
	 * @return void
	 */
	public function mark_failed( $wp_error ) {
		$mail_data = array();
		if ( is_wp_error( $wp_error ) ) {
			$candidate = $wp_error->get_error_data( 'wp_mail_failed' );
			if ( is_array( $candidate ) ) {
				$mail_data = $candidate;
			}
		}

		$event = $this->take_pending_event( $mail_data );
		if ( ! $event ) {
			return;
		}

		$error_message = is_wp_error( $wp_error ) ? $this->sanitize_error_message( $wp_error->get_error_message() ) : '';

		$this->events->mark_failed( $event->id, $error_message );

		if ( ! empty( $event->lead_id ) ) {
			$this->update_lead_after_event( absint( $event->lead_id ), sanitize_key( $event->source ?? '' ), LeadRepository::EMAIL_STATUS_FAILED );
		}
	}

	/**
	 * Update lead email status based on related mail events.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $source Mail source.
	 * @param string $fallback Event status fallback.
	 * @return void
	 */
	public function update_lead_after_event( $lead_id, $source = '', $fallback = LeadRepository::EMAIL_STATUS_UNKNOWN ) {
		if ( '' === sanitize_key( (string) $source ) ) {
			$this->lead_email_status->recalculate( $lead_id, $fallback );
			return;
		}

		$this->lead_email_status->recalculate_for_source( $lead_id, $source, $fallback );
	}

	/**
	 * Record a pre_wp_mail short circuit, which skips all normal mail hooks.
	 *
	 * WordPress exposes no hook after every pre_wp_mail callback has run. Running
	 * at the maximum priority observes the final result as closely as the public
	 * API permits. A shutdown reconciliation covers a pending event when another
	 * same-priority callback later bypasses the terminal hooks.
	 *
	 * @param mixed               $short_circuit Short-circuit result.
	 * @param array<string,mixed> $mail_data Original wp_mail attributes.
	 * @return mixed
	 */
	public function observe_short_circuit( $short_circuit, $mail_data ) {
		if ( null === $short_circuit || $this->context->should_suppress_mail_logging() || ! is_array( $mail_data ) ) {
			return $short_circuit;
		}

		$event = $this->take_pending_event( $mail_data, false );
		if ( ! $event ) {
			$event_id = $this->create_pending_event(
				$mail_data,
				array( 'reason_code' => 'pre_wp_mail_short_circuit' )
			);
			$event = $event_id > 0 ? $this->events->get( $event_id ) : null;
		} else {
			// The normal wp_mail filter already created this event. Preserve that
			// the transport path was bypassed instead of presenting it as an
			// ordinary WordPress acceptance later in the audit trail.
			$this->events->update_metadata(
				$event->id,
				array( 'reason_code' => 'pre_wp_mail_short_circuit' )
			);
			$event->reason_code = 'pre_wp_mail_short_circuit';
		}

		if ( ! $event ) {
			return $short_circuit;
		}

		if ( true === $short_circuit ) {
			$this->events->mark_sent( $event->id );
			$fallback = LeadRepository::EMAIL_STATUS_SENT;
		} else {
			$this->events->mark_failed( $event->id, __( 'WordPress mail was short-circuited before the transport accepted it.', 'submission-inbox' ) );
			$fallback = LeadRepository::EMAIL_STATUS_FAILED;
		}

		if ( ! empty( $event->lead_id ) ) {
			$this->update_lead_after_event( absint( $event->lead_id ), sanitize_key( $event->source ?? '' ), $fallback );
		}

		return $short_circuit;
	}

	/**
	 * Resolve synchronous calls that never emitted a terminal WordPress hook.
	 *
	 * @return void
	 */
	public function finalize_unresolved_events() {
		$frames               = array_reverse( $this->pending_events );
		$this->pending_events = array();

		foreach ( $frames as $frame ) {
			$event = $this->events->get( absint( $frame['id'] ?? 0 ) );
			if ( ! $event || MailEventRepository::STATUS_PENDING !== sanitize_key( $event->status ?? '' ) ) {
				continue;
			}

			if ( method_exists( $this->events, 'mark_unknown' ) ) {
				$this->events->mark_unknown(
					$event->id,
					__( 'WordPress mail ended without a success or failure hook.', 'submission-inbox' ),
					'terminal_hook_missing'
				);
			}

			if ( ! empty( $event->lead_id ) ) {
				$this->update_lead_after_event( absint( $event->lead_id ), sanitize_key( $event->source ?? '' ), LeadRepository::EMAIL_STATUS_UNKNOWN );
			}
		}
	}

	/**
	 * Create one metadata-only pending event.
	 *
	 * @param array<string,mixed> $mail_data Mail data.
	 * @param array<string,mixed> $extra_metadata Additional trusted metadata.
	 * @return int
	 */
	private function create_pending_event( array $mail_data, array $extra_metadata = array() ) {
		$source   = $this->context->get_current_source() ? $this->context->get_current_source() : 'wp_mail';
		$metadata = array_merge( $this->context->get_delivery_metadata(), $extra_metadata );
		$lead_id  = absint( $this->context->get_current_lead_id() );
		if (
			$lead_id > 0
			&& method_exists( $this->leads, 'get' )
			&& ( empty( $metadata['provider'] ) || empty( $metadata['form_id'] ) || empty( $metadata['form_name'] ) )
		) {
			$lead = $this->leads->get( $lead_id );
			if ( is_object( $lead ) ) {
				if ( empty( $metadata['provider'] ) ) {
					$metadata['provider'] = $lead->provider ?? '';
				}
				if ( empty( $metadata['form_id'] ) ) {
					$metadata['form_id'] = $lead->form_id ?? '';
				}
				if ( empty( $metadata['form_name'] ) ) {
					$metadata['form_name'] = $lead->form_name ?? '';
				}
			}
		}
		if ( empty( $metadata['event_kind'] ) ) {
			$metadata['event_kind'] = $this->resolve_event_kind( $source, $metadata );
		}

		return $this->events->create_pending(
			array_merge(
				array(
					'lead_id'       => $lead_id > 0 ? $lead_id : null,
					'correlation_id' => $this->context->get_correlation_id(),
					'source'         => $source,
					'to_email'       => $this->normalize_recipients( $mail_data['to'] ?? '' ),
					'from_email'     => $this->resolve_logged_from_email( $mail_data['headers'] ?? array() ),
					'subject'        => sanitize_text_field( $mail_data['subject'] ?? '' ),
					'message_hash'   => hash( 'sha256', (string) ( $mail_data['message'] ?? '' ) ),
				),
				$metadata
			)
		);
	}

	/**
	 * Resolve a purpose only where the plugin owns the call context.
	 *
	 * @param string              $source Mail source.
	 * @param array<string,mixed> $metadata Delivery metadata.
	 * @return string
	 */
	private function resolve_event_kind( $source, array $metadata ) {
		$scoped = MailEventTypeContext::current();
		if ( '' !== $scoped ) {
			return $scoped;
		}

		$source = sanitize_key( (string) $source );
		if ( 'submission_inbox_auto_reply' === $source ) {
			return 'manual_test' === sanitize_key( (string) ( $metadata['reason_code'] ?? '' ) )
				? MailEventTypeContext::KIND_AUTO_REPLY_TEST
				: MailEventTypeContext::KIND_VISITOR_CONFIRMATION;
		}

		if ( 'submission_inbox_form' === $source ) {
			return MailEventTypeContext::KIND_ADMIN_NOTIFICATION;
		}

		return MailEventTypeContext::KIND_GENERIC;
	}

	/**
	 * Take the matching pending frame without confusing nested wp_mail calls.
	 *
	 * Matching walks newest-first. That is both re-entrant safe and deterministic
	 * for identical nested messages, whose inner call always completes first.
	 *
	 * @param array<string,mixed> $mail_data Terminal hook data, when available.
	 * @param bool                $fallback_to_latest Whether unmatched hook data may use the active call frame.
	 * @return object|null
	 */
	private function take_pending_event( array $mail_data = array(), $fallback_to_latest = true ) {
		if ( empty( $this->pending_events ) ) {
			return null;
		}

		$index = count( $this->pending_events ) - 1;
		if ( ! empty( $mail_data ) ) {
			$fingerprint = $this->mail_fingerprint( $mail_data );
			$matched     = false;
			for ( $candidate = $index; $candidate >= 0; $candidate-- ) {
				if ( hash_equals( (string) $this->pending_events[ $candidate ]['fingerprint'], $fingerprint ) ) {
					$index = $candidate;
					$matched = true;
					break;
				}
			}
			if ( ! $matched && ! $fallback_to_latest ) {
				return null;
			}
		}

		$frame = $this->pending_events[ $index ];
		array_splice( $this->pending_events, $index, 1 );
		return $this->events->get( absint( $frame['id'] ?? 0 ) );
	}

	/**
	 * Build a one-way association key for terminal hooks.
	 *
	 * @param array<string,mixed> $mail_data Mail data.
	 * @return string
	 */
	private function mail_fingerprint( array $mail_data ) {
		$recipients = $mail_data['to'] ?? array();
		$recipients = is_array( $recipients ) ? $recipients : explode( ',', (string) $recipients );
		$recipients = array_map(
			static function ( $recipient ) {
				return strtolower( trim( sanitize_text_field( (string) $recipient ) ) );
			},
			$recipients
		);
		sort( $recipients, SORT_STRING );

		$attachments = $mail_data['attachments'] ?? array();
		$attachments = is_array( $attachments ) ? $attachments : explode( "\n", (string) $attachments );
		$attachments = array_map( 'strval', $attachments );
		sort( $attachments, SORT_STRING );

		return hash(
			'sha256',
			wp_json_encode(
				array(
					'to'          => $recipients,
					'subject'     => sanitize_text_field( $mail_data['subject'] ?? '' ),
					'message_hash' => hash( 'sha256', (string) ( $mail_data['message'] ?? '' ) ),
					'attachments' => $attachments,
				)
			)
		);
	}

	/**
	 * Normalize recipients to a display string.
	 *
	 * @param mixed $recipients Raw recipients.
	 * @return string
	 */
	private function normalize_recipients( $recipients ) {
		if ( is_array( $recipients ) ) {
			$recipients = implode( ', ', array_map( 'sanitize_text_field', $recipients ) );
		}

		return sanitize_text_field( (string) $recipients );
	}

	/**
	 * Extract From email from wp_mail headers.
	 *
	 * @param mixed $headers Headers.
	 * @return string
	 */
	private function extract_from_email( $headers ) {
		if ( is_string( $headers ) ) {
			$headers = explode( "\n", $headers );
		}

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

		foreach ( $headers as $header ) {
			$header = trim( (string) $header );
			if ( 0 !== stripos( $header, 'from:' ) ) {
				continue;
			}

			if ( preg_match( '/<([^>]+)>/', $header, $matches ) ) {
				return sanitize_email( $matches[1] );
			}

			return sanitize_email( trim( substr( $header, 5 ) ) );
		}

		return '';
	}

	/**
	 * Resolve the From email shown in the log.
	 *
	 * @param mixed $headers Mail headers.
	 * @return string
	 */
	private function resolve_logged_from_email( $headers ) {
		$from = $this->extract_from_email( $headers );
		if ( '' !== $from && is_email( $from ) ) {
			return $from;
		}

		$settings_from = sanitize_email( (string) ( $this->settings->all()['from_email'] ?? '' ) );
		if ( '' !== $settings_from && is_email( $settings_from ) ) {
			return $settings_from;
		}

		$admin_email = sanitize_email( (string) get_option( 'admin_email' ) );
		return is_email( $admin_email ) ? $admin_email : '';
	}

	/**
	 * Sanitize a mail error before saving it to the log.
	 *
	 * @param string $message Error message.
	 * @return string
	 */
	private function sanitize_error_message( $message ) {
		$settings = $this->settings->all();
		return MailErrorSanitizer::sanitize(
			$message,
			array( $settings['smtp_password'] ?? '', $settings['smtp_username'] ?? '' )
		);
	}
}
