<?php
/**
 * Lead mail status projections.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Mail;

use SUBIN\Leads\LeadRepository;
use SUBIN\MailLog\MailEventRepository;

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

/**
 * Keeps team notification and visitor auto-reply outcomes independent.
 */
class LeadEmailStatusService {
	const MAX_PROJECTION_CAS_ATTEMPTS = 3;

	const CHANNEL_ADMIN_NOTIFICATION = 'admin_notification';
	const CHANNEL_VISITOR_AUTO_REPLY = 'visitor_auto_reply';
	const CHANNEL_OTHER              = 'other';
	const AUTO_REPLY_SOURCE          = 'submission_inbox_auto_reply';
	const ADMIN_NOTIFICATION_SOURCES = array(
		'submission_inbox_form',
		'contact_form_7',
		'wpforms',
		'fluent_forms',
		'forminator',
		'ninja_forms',
		'gravity_forms',
	);

	/** @var LeadRepository */
	private $leads;

	/** @var MailEventRepository */
	private $events;

	/**
	 * Constructor.
	 *
	 * @param LeadRepository|null      $leads Leads.
	 * @param MailEventRepository|null $events Events.
	 */
	public function __construct( ?LeadRepository $leads = null, ?MailEventRepository $events = null ) {
		$this->leads  = $leads ? $leads : new LeadRepository();
		$this->events = $events ? $events : new MailEventRepository();
	}

	/**
	 * Register durable projection refresh hooks.
	 *
	 * @return void
	 */
	public function register() {
		add_action( 'subin_mail_event_status_updated', array( $this, 'handle_event_status_updated' ), 10, 2 );
		add_action( 'subin_mail_event_no_email_generated', array( $this, 'handle_no_email_generated' ) );
	}

	/**
	 * Refresh the correct projection after a terminal event update.
	 *
	 * @param int    $event_id Event id.
	 * @param string $status Event status.
	 * @return void
	 */
	public function handle_event_status_updated( $event_id, $status ) {
		$this->recalculate_for_event( $event_id, $status );
	}

	/**
	 * Refresh the correct projection after a no-email outcome is inserted.
	 *
	 * @param int $event_id Event id.
	 * @return void
	 */
	public function handle_no_email_generated( $event_id ) {
		$this->recalculate_for_event( $event_id, MailEventRepository::STATUS_NO_EMAIL_GENERATED );
	}

	/**
	 * Recalculate and persist one lead mail channel.
	 *
	 * Existing callers without a channel continue to update the historical
	 * email_status column, which now explicitly means the team notification.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $fallback Fallback status.
	 * @param string $channel Mail channel.
	 * @return string
	 */
	public function recalculate( $lead_id, $fallback = LeadRepository::EMAIL_STATUS_UNKNOWN, $channel = self::CHANNEL_ADMIN_NOTIFICATION ) {
		$lead_id = absint( $lead_id );
		$channel = $this->normalize_channel( $channel );
		$status  = $this->sanitize_fallback( $fallback, $channel );

		for ( $attempt = 0; $attempt < self::MAX_PROJECTION_CAS_ATTEMPTS; ++$attempt ) {
			$current = self::CHANNEL_VISITOR_AUTO_REPLY === $channel
				? $this->leads->get_auto_reply_status_projection( $lead_id )
				: $this->leads->get_email_status_projection( $lead_id );
			if ( is_wp_error( $current ) ) {
				do_action( 'subin_lead_mail_projection_failed', $lead_id, $channel, $current );
				return $status;
			}

			$status = $this->derive_status( $lead_id, $fallback, $channel );
			if ( $status === sanitize_key( (string) ( $current['status'] ?? '' ) ) ) {
				return $status;
			}

			$updated = self::CHANNEL_VISITOR_AUTO_REPLY === $channel
				? $this->leads->update_auto_reply_status_if_current( $lead_id, $status, $current['version'] ?? 0 )
				: $this->leads->update_email_status_if_current( $lead_id, $status, $current['version'] ?? 0 );
			if ( true === $updated ) {
				return $status;
			}
			if ( ! is_wp_error( $updated ) || false === strpos( (string) $updated->get_error_code(), '_projection_conflict' ) ) {
				do_action( 'subin_lead_mail_projection_failed', $lead_id, $channel, $updated );
				return $status;
			}
		}

		do_action(
			'subin_lead_mail_projection_failed',
			$lead_id,
			$channel,
			new \WP_Error(
				'subin_lead_mail_projection_conflict',
				__( 'The lead delivery status changed repeatedly while it was being recalculated.', 'submission-inbox' )
			)
		);

		return $status;
	}

	/**
	 * Recalculate the projection associated with one mail source.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $source Event source.
	 * @param string $fallback Fallback status.
	 * @return string
	 */
	public function recalculate_for_source( $lead_id, $source, $fallback = LeadRepository::EMAIL_STATUS_UNKNOWN ) {
		$channel = $this->channel_for_source( $source );
		if ( self::CHANNEL_OTHER === $channel ) {
			return sanitize_key( (string) $fallback );
		}

		return $this->recalculate( $lead_id, $fallback, $channel );
	}

	/**
	 * Derive one channel without allowing the other channel to affect it.
	 *
	 * @param int    $lead_id Lead id.
	 * @param string $fallback Fallback.
	 * @param string $channel Mail channel.
	 * @return string
	 */
	public function derive_status( $lead_id, $fallback = LeadRepository::EMAIL_STATUS_UNKNOWN, $channel = self::CHANNEL_ADMIN_NOTIFICATION ) {
		$channel         = $this->normalize_channel( $channel );
		$has_pending     = false;
		$seen_deliveries = array();

		// Repository order is newest first. Email Log keeps older failures; the
		// Inbox projection represents the newest distinct delivery attempt.
		foreach ( $this->events->get_by_lead_id( $lead_id ) as $event ) {
			$source = sanitize_key( $event->source ?? '' );
			if ( ! $this->source_matches_channel( $source, $channel ) ) {
				continue;
			}

			$correlation_id = sanitize_text_field( $event->correlation_id ?? '' );
			if ( '' !== $correlation_id ) {
				$delivery_key = $source . ':' . $correlation_id;
				if ( isset( $seen_deliveries[ $delivery_key ] ) ) {
					continue;
				}
				$seen_deliveries[ $delivery_key ] = true;
			}

			$status = sanitize_key( $event->status ?? '' );
			if ( MailEventRepository::STATUS_FAILED === $status ) {
				return LeadRepository::EMAIL_STATUS_FAILED;
			}
			if ( MailEventRepository::STATUS_NO_EMAIL_GENERATED === $status ) {
				return LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED;
			}
			if ( MailEventRepository::STATUS_SENT === $status ) {
				return LeadRepository::EMAIL_STATUS_SENT;
			}
			if ( MailEventRepository::STATUS_PENDING === $status ) {
				if ( self::CHANNEL_VISITOR_AUTO_REPLY === $channel ) {
					return LeadRepository::AUTO_REPLY_STATUS_SCHEDULED;
				}
				$has_pending = true;
			}
		}

		if ( $has_pending ) {
			return LeadRepository::EMAIL_STATUS_UNKNOWN;
		}

		return $this->sanitize_fallback( $fallback, $channel );
	}

	/**
	 * Resolve the projection channel for a mail event source.
	 *
	 * @param string $source Event source.
	 * @return string
	 */
	public function channel_for_source( $source ) {
		$source = sanitize_key( (string) $source );
		if ( self::AUTO_REPLY_SOURCE === $source ) {
			return self::CHANNEL_VISITOR_AUTO_REPLY;
		}

		return in_array( $source, self::ADMIN_NOTIFICATION_SOURCES, true )
			? self::CHANNEL_ADMIN_NOTIFICATION
			: self::CHANNEL_OTHER;
	}

	/**
	 * Refresh a projection from one stored event.
	 *
	 * @param int    $event_id Event id.
	 * @param string $fallback Event status fallback.
	 * @return void
	 */
	private function recalculate_for_event( $event_id, $fallback ) {
		$event = $this->events->get( absint( $event_id ) );
		if ( ! $event || absint( $event->lead_id ?? 0 ) <= 0 ) {
			return;
		}

		$this->recalculate_for_source(
			absint( $event->lead_id ),
			sanitize_key( $event->source ?? '' ),
			sanitize_key( (string) $fallback )
		);
	}

	/**
	 * Whether a source belongs to the requested projection.
	 *
	 * @param string $source Event source.
	 * @param string $channel Mail channel.
	 * @return bool
	 */
	private function source_matches_channel( $source, $channel ) {
		return $this->channel_for_source( $source ) === $channel;
	}

	/**
	 * Normalize a requested channel.
	 *
	 * @param string $channel Channel.
	 * @return string
	 */
	private function normalize_channel( $channel ) {
		return self::CHANNEL_VISITOR_AUTO_REPLY === sanitize_key( (string) $channel )
			? self::CHANNEL_VISITOR_AUTO_REPLY
			: self::CHANNEL_ADMIN_NOTIFICATION;
	}

	/**
	 * Keep a fallback inside the channel's persisted allowlist.
	 *
	 * @param string $fallback Fallback.
	 * @param string $channel Channel.
	 * @return string
	 */
	private function sanitize_fallback( $fallback, $channel ) {
		$fallback = sanitize_key( (string) $fallback );
		if ( self::CHANNEL_VISITOR_AUTO_REPLY === $channel ) {
			$allowed = array(
				LeadRepository::AUTO_REPLY_STATUS_NOT_CONFIGURED,
				LeadRepository::AUTO_REPLY_STATUS_SCHEDULED,
				LeadRepository::AUTO_REPLY_STATUS_SENT,
				LeadRepository::AUTO_REPLY_STATUS_FAILED,
				LeadRepository::AUTO_REPLY_STATUS_NO_EMAIL_GENERATED,
				LeadRepository::AUTO_REPLY_STATUS_UNKNOWN,
			);

			return in_array( $fallback, $allowed, true ) ? $fallback : LeadRepository::AUTO_REPLY_STATUS_UNKNOWN;
		}

		$allowed = array(
			LeadRepository::EMAIL_STATUS_SENT,
			LeadRepository::EMAIL_STATUS_FAILED,
			LeadRepository::EMAIL_STATUS_NO_EMAIL_GENERATED,
			LeadRepository::EMAIL_STATUS_UNKNOWN,
		);

		return in_array( $fallback, $allowed, true ) ? $fallback : LeadRepository::EMAIL_STATUS_UNKNOWN;
	}
}
