<?php
/**
 * The feed processor — the only place a submission becomes an API call.
 *
 * @package RewindBrevoFormBridge
 */

namespace RewindBrevoFormBridge\Feeds;

use RewindBrevoFormBridge\Adapters\Submission;
use RewindBrevoFormBridge\Api\Attributes;
use RewindBrevoFormBridge\Api\Contacts;
use RewindBrevoFormBridge\Api\Response;
use RewindBrevoFormBridge\Admin\Settings;
use RewindBrevoFormBridge\Mapping\Coercer;
use RewindBrevoFormBridge\Mapping\Phone;
use RewindBrevoFormBridge\Support\Errors;

defined( 'ABSPATH' ) || exit;

/**
 * Runs every active feed for a submission through: email resolution,
 * consent check, coercion, payload build, upsert, log row.
 *
 * Processing is synchronous in Phase 3; Phase 4 moves the API call onto a
 * queue and extracts the inline log writes into Log\Logger.
 *
 * @since 0.3.0
 */
class Processor {

	/**
	 * Feed repository.
	 *
	 * @var Repository
	 */
	private Repository $feeds;

	/**
	 * Contacts resource.
	 *
	 * @var Contacts
	 */
	private Contacts $contacts;

	/**
	 * Attributes resource (for enumeration / multi-option context).
	 *
	 * @var Attributes
	 */
	private Attributes $attributes;

	/**
	 * Coercer.
	 *
	 * @var Coercer
	 */
	private Coercer $coercer;

	/**
	 * Phone normaliser.
	 *
	 * @var Phone
	 */
	private Phone $phone;

	/**
	 * Settings (default country, site date format).
	 *
	 * @var Settings
	 */
	private Settings $settings;

	/**
	 * Constructor.
	 *
	 * @since 0.3.0
	 * @param Repository $feeds      Feed repository.
	 * @param Contacts   $contacts   Contacts resource.
	 * @param Attributes $attributes Attributes resource.
	 * @param Coercer    $coercer    Type coercer.
	 * @param Phone      $phone      Phone normaliser.
	 * @param Settings   $settings   Settings.
	 */
	public function __construct( Repository $feeds, Contacts $contacts, Attributes $attributes, Coercer $coercer, Phone $phone, Settings $settings ) {
		$this->feeds      = $feeds;
		$this->contacts   = $contacts;
		$this->attributes = $attributes;
		$this->coercer    = $coercer;
		$this->phone      = $phone;
		$this->settings   = $settings;
	}

	/**
	 * Process one submission against every active feed of its form.
	 *
	 * @since 0.3.0
	 * @param Submission $submission The normalised submission.
	 * @return void
	 */
	public function process( Submission $submission ): void {
		$feeds = $this->feeds->find_for( $submission->adapter(), $submission->form_id() );

		foreach ( $feeds as $feed ) {
			$this->process_feed( $feed, $submission );
		}
	}

	/**
	 * Run one feed.
	 *
	 * @since 0.3.0
	 * @param Feed       $feed       The feed.
	 * @param Submission $submission The submission.
	 * @return void
	 */
	private function process_feed( Feed $feed, Submission $submission ): void {
		// 1. Email — no email, no push.
		$email = sanitize_email( (string) $submission->field( $feed->email_field(), '' ) );

		if ( ! is_email( $email ) ) {
			$this->log( $feed, $submission, '', 'skipped', null, '', __( 'No valid email address in the mapped email field — nothing was sent.', 'rewind-form-bridge-for-brevo' ), null, null );
			return;
		}

		// 2. Consent. A missing/unticked consent box is a NORMAL outcome.
		if ( $feed->consent_required() ) {
			$consent = $this->coercer->coerce( $submission->field( $feed->consent_field(), '' ), 'boolean' );

			if ( true !== $consent['value'] ) {
				$this->log( $feed, $submission, $email, 'skipped', null, '', __( 'Consent not given — the visitor did not tick the consent box.', 'rewind-form-bridge-for-brevo' ), null, null );
				return;
			}
		}

		// 3. Coerce every mapping.
		$build = $this->build_attributes( $feed, $submission );

		if ( '' !== $build['fatal'] ) {
			$this->log( $feed, $submission, $email, 'failed', null, 'invalid_parameter', $build['fatal'], null, null );
			return;
		}

		// 4. Payload.
		$payload = array(
			'email'         => $email,
			'attributes'    => (object) $build['attributes'],
			'listIds'       => $feed->list_ids(),
			'updateEnabled' => $feed->update_existing(),
		);

		/**
		 * Filter the final Brevo contact payload before it is sent.
		 *
		 * @since 0.3.0
		 * @param array      $payload    The POST /contacts body.
		 * @param Feed       $feed       The feed being processed.
		 * @param Submission $submission The submission.
		 */
		$payload = apply_filters( 'rbfb_contact_payload', $payload, $feed, $submission );

		// 5. Push.
		$response = $this->contacts->upsert( $payload );

		// 6. One log row per feed per submission.
		$notes = 0 === count( $build['notes'] ) ? '' : ' ' . implode( ' ', $build['notes'] );

		if ( $response->is_success() ) {
			$created = 201 === $response->status();
			$this->log(
				$feed,
				$submission,
				$email,
				'success',
				$response->status(),
				'',
				( $created
					? __( 'Contact created in Brevo.', 'rewind-form-bridge-for-brevo' )
					: __( 'Existing contact updated in Brevo.', 'rewind-form-bridge-for-brevo' )
				) . $notes,
				$payload,
				$response
			);
			return;
		}

		// duplicate_parameter with updates disabled = repeat submitter,
		// deliberately ignored. A soft success, not an error.
		if ( 400 === $response->status() && 'duplicate_parameter' === $response->error_code() && ! $feed->update_existing() ) {
			$this->log( $feed, $submission, $email, 'skipped', $response->status(), $response->error_code(), __( 'Contact already exists and this feed does not update existing contacts.', 'rewind-form-bridge-for-brevo' ) . $notes, $payload, $response );
			return;
		}

		$this->log( $feed, $submission, $email, 'failed', $response->status(), $response->error_code(), Errors::translate( $response ) . $notes, $payload, $response );
	}

	/**
	 * Coerce all mappings into the attributes array.
	 *
	 * @since 0.3.0
	 * @param Feed       $feed       The feed.
	 * @param Submission $submission The submission.
	 * @return array ['attributes' => array, 'notes' => string[], 'fatal' => string]
	 */
	private function build_attributes( Feed $feed, Submission $submission ): array {
		$attributes = array();
		$notes      = array();
		$meta       = $this->attribute_meta();

		foreach ( $feed->mappings() as $mapping ) {
			$name    = strtoupper( $mapping['attribute'] );
			$value   = $submission->field( $mapping['field_key'] );
			$context = array(
				'required'         => $mapping['required'],
				'date_format'      => $mapping['date_format'],
				'site_date_format' => (string) get_option( 'date_format', '' ),
			);

			if ( isset( $meta[ $name ] ) ) {
				$context['enumeration']   = $meta[ $name ]['enumeration'];
				$context['multi_options'] = $meta[ $name ]['multi_options'];
			}

			// SMS/WhatsApp values must carry a country code or Brevo rejects
			// or mis-stores them — normalise before type coercion.
			if ( in_array( $name, array( 'SMS', 'WHATSAPP', 'LANDLINE_NUMBER' ), true ) ) {
				$normalized = $this->phone->normalize( $value, (string) $this->settings->get( 'default_country', '' ) );

				if ( ! $normalized['ok'] ) {
					if ( $mapping['required'] ) {
						return array(
							'attributes' => array(),
							'notes'      => array(),
							'fatal'      => sprintf(
								/* translators: 1: attribute name, 2: rejection reason. */
								__( 'Required field %1$s rejected: %2$s', 'rewind-form-bridge-for-brevo' ),
								$name,
								$normalized['reason']
							),
						);
					}

					/* translators: 1: attribute name, 2: rejection reason. */
					$notes[] = sprintf( __( 'Dropped %1$s: %2$s', 'rewind-form-bridge-for-brevo' ), $name, $normalized['reason'] );
					continue;
				}

				$attributes[ $name ] = $normalized['value'];
				continue;
			}

			$result = $this->coercer->coerce( $value, $mapping['brevo_type'], $context );

			if ( ! $result['ok'] ) {
				if ( $mapping['required'] ) {
					return array(
						'attributes' => array(),
						'notes'      => array(),
						'fatal'      => sprintf(
							/* translators: 1: attribute name, 2: rejection reason. */
							__( 'Required field %1$s rejected: %2$s', 'rewind-form-bridge-for-brevo' ),
							$name,
							$result['reason']
						),
					);
				}

				/* translators: 1: attribute name, 2: rejection reason. */
				$notes[] = sprintf( __( 'Dropped %1$s: %2$s', 'rewind-form-bridge-for-brevo' ), $name, $result['reason'] );
				continue;
			}

			if ( '' !== $result['reason'] ) {
				$notes[] = $name . ': ' . $result['reason'];
			}

			$attributes[ $name ] = $result['value'];
		}

		return array(
			'attributes' => $attributes,
			'notes'      => $notes,
			'fatal'      => '',
		);
	}

	/**
	 * Attribute metadata keyed by uppercase name, cache-first. During a real
	 * submission a cache miss MAY refetch (justified as part of processing);
	 * on API failure coercion simply runs without enumeration context.
	 *
	 * @since 0.3.0
	 * @return array<string, array>
	 */
	private function attribute_meta(): array {
		$all = $this->attributes->all( false );

		if ( is_wp_error( $all ) ) {
			return array();
		}

		$meta = array();
		foreach ( $all as $attribute ) {
			$meta[ strtoupper( $attribute['name'] ) ] = array(
				'enumeration'   => $attribute['enumeration'],
				'multi_options' => $attribute['multi_options'],
			);
		}

		return $meta;
	}

	/**
	 * Write one delivery-log row. Inline in Phase 3; Phase 4 extracts this
	 * into Log\Logger with queue statuses and retention.
	 *
	 * @since 0.3.0
	 * @param Feed          $feed       Feed.
	 * @param Submission    $submission Submission.
	 * @param string        $email      Resolved email ('' when unresolved).
	 * @param string        $status     success|failed|skipped.
	 * @param int|null      $http_code  HTTP status when an API call happened.
	 * @param string        $error_code Brevo error code when present.
	 * @param string        $message    Human explanation for the log screen.
	 * @param array|null    $request    Payload sent (API key never present).
	 * @param Response|null $response   API response when one exists.
	 * @return void
	 */
	private function log( Feed $feed, Submission $submission, string $email, string $status, ?int $http_code, string $error_code, string $message, ?array $request, ?Response $response ): void {
		global $wpdb;

		$now = current_time( 'mysql', true );

		// Custom table, no WordPress API for it, and nothing to cache: this
		// row is written once and read back only by the log screen.
		$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prefix . 'rbfb_log',
			array(
				'feed_id'    => $feed->id(),
				'entry_ref'  => $submission->entry_ref(),
				'email'      => $email,
				'status'     => $status,
				'http_code'  => $http_code,
				'error_code' => '' !== $error_code ? $error_code : null,
				'message'    => $this->truncate( $message ),
				'request'    => null !== $request ? $this->truncate( (string) wp_json_encode( $request ) ) : null,
				'response'   => null !== $response ? $this->truncate( $response->raw_body() ) : null,
				'attempts'   => null !== $response ? 1 : 0,
				'created_at' => $now,
				'updated_at' => $now,
			)
		);

		$this->track_failure_streak( $status, $message );
	}

	/**
	 * Keep the consecutive-failure counter, and alert once when it trips.
	 *
	 * A single failed delivery is noise; a run of them means the key was
	 * revoked, the account is suspended, or Brevo is down — the cases a site
	 * owner wants an email about. The streak resets on the next success, so
	 * one alert goes out per outage rather than one per submission.
	 *
	 * @since 1.0.0
	 * @param string $status  Log status just written.
	 * @param string $message Human explanation stored with the row.
	 * @return void
	 */
	private function track_failure_streak( string $status, string $message ): void {
		if ( 'success' === $status ) {
			delete_option( 'rbfb_failure_streak' );
			return;
		}

		if ( 'failed' !== $status ) {
			return; // 'skipped' is a normal outcome, not a fault.
		}

		$streak    = (int) get_option( 'rbfb_failure_streak', 0 ) + 1;
		$threshold = max( 1, (int) $this->settings->get( 'alert_failure_threshold', 5 ) );

		update_option( 'rbfb_failure_streak', $streak, false );

		if ( $streak === $threshold ) {
			$this->send_failure_alert( $streak, $message );
		}
	}

	/**
	 * Email the site owner that deliveries are failing in a row.
	 *
	 * Sent with wp_mail() so it follows whatever transport the site uses. If
	 * that transport is this plugin's own Brevo mailer and Brevo is the thing
	 * that is down, the alert will not arrive — the delivery log on the
	 * dashboard stays the authoritative record either way.
	 *
	 * @since 1.0.0
	 * @param int    $streak  Consecutive failures counted.
	 * @param string $message Explanation from the most recent failure.
	 * @return void
	 */
	private function send_failure_alert( int $streak, string $message ): void {
		// The settings screen promises the site admin address when the field
		// is left blank, so honour that rather than going silent.
		$to = trim( (string) $this->settings->get( 'alert_email', '' ) );

		if ( '' === $to ) {
			$to = (string) get_option( 'admin_email', '' );
		}

		if ( ! is_email( $to ) ) {
			return;
		}

		$subject = sprintf(
			/* translators: %s: site name. */
			__( '[%s] Brevo contact deliveries are failing', 'rewind-form-bridge-for-brevo' ),
			wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES )
		);

		$body = sprintf(
			/* translators: 1: number of consecutive failures, 2: the most recent error message. */
			__( 'The last %1$d form submissions could not be sent to Brevo. The most recent error was: %2$s', 'rewind-form-bridge-for-brevo' ),
			$streak,
			$message
		) . "\n\n" . __( 'Open the delivery log for the full request and response:', 'rewind-form-bridge-for-brevo' )
			. "\n" . admin_url( 'admin.php?page=rewind-form-bridge-for-brevo#/logs' );

		wp_mail( $to, $subject, $body );
	}

	/**
	 * Cap stored bodies at 64KB.
	 *
	 * @param string $text Raw text.
	 * @return string
	 */
	private function truncate( string $text ): string {
		return strlen( $text ) > 65536 ? substr( $text, 0, 65533 ) . '...' : $text;
	}
}
