<?php
namespace Pay4All\Pro\Services;

use Pay4All\Pro\Activator;
use Pay4All\Pro\Domain\Newsletter;
use Pay4All\Pro\Domain\NewsletterSendLog;
use Pay4All\Pro\License\LicenseManager;

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

/**
 * Cron-driven newsletter broadcaster.
 *
 * Every tick :
 *   1. Fetch the oldest queued / sending campaign (only one per tick to
 *      keep the cron slice short + predictable).
 *   2. Pop the next batch of customers (cursor = campaign's
 *      last_customer_id + 1, walking wp_p4all_customers ordered by id).
 *   3. For each customer : resolve language, render template with
 *      NewsletterSubstitutor, wp_mail, record success/failure.
 *   4. Advance last_customer_id ; when the batch returns empty, flip the
 *      campaign to done.
 *
 * The cron schedule interval is driven by the free plugin's
 * newsletter_batch_delay setting (default 15 min). We register a custom
 * schedule ('p4all_pro_newsletter') via cron_schedules so a merchant who
 * changes the interval sees the new period picked up on the next tick.
 */
final class NewsletterSpooler {

	public static function register() : void {
		add_filter( 'cron_schedules',                    [ self::class, 'register_schedule' ] );
		add_action( Activator::CRON_HOOK_NEWSLETTER,     [ self::class, 'tick' ] );
		// Reschedule the cron whenever the merchant saves a new batch
		// delay so a shorter interval takes effect right away instead of
		// waiting for the next natural tick to notice the change.
		add_action( 'update_option_p4all_form_settings', [ self::class, 'on_settings_saved' ], 10, 2 );
	}

	/**
	 * @param mixed $old
	 * @param mixed $new
	 */
	public static function on_settings_saved( $old, $new ) : void {
		$old_delay = is_array( $old ) && isset( $old['newsletter_batch_delay'] ) ? (int) $old['newsletter_batch_delay'] : 0;
		$new_delay = is_array( $new ) && isset( $new['newsletter_batch_delay'] ) ? (int) $new['newsletter_batch_delay'] : 0;
		if ( $new_delay > 0 && $new_delay !== $old_delay ) {
			self::reschedule();
		}
	}

	/**
	 * Custom cron interval. Reads the merchant's newsletter_batch_delay
	 * setting (minutes) so a save on Réglages / E-mail / Mailing tightens
	 * or loosens the tick without touching wp_options.
	 *
	 * @param array<string,array{interval:int,display:string}> $schedules
	 * @return array<string,array{interval:int,display:string}>
	 */
	public static function register_schedule( $schedules ) : array {
		if ( ! is_array( $schedules ) ) { $schedules = []; }
		$minutes = self::batch_delay_minutes();
		$schedules['p4all_pro_newsletter'] = [
			'interval' => max( 60, $minutes * 60 ),
			/* translators: %d = minutes between newsletter batches */
			'display'  => sprintf( __( 'Pay4All Pro : every %d min (newsletter spooler)', 'pay4all-pro' ), $minutes ),
		];
		return $schedules;
	}

	/**
	 * Reschedules the cron with the current interval — call after saving
	 * the newsletter_batch_delay setting so a shorter interval doesn't
	 * wait for the next natural tick to activate.
	 */
	public static function reschedule() : void {
		$ts = wp_next_scheduled( Activator::CRON_HOOK_NEWSLETTER );
		if ( $ts ) { wp_unschedule_event( $ts, Activator::CRON_HOOK_NEWSLETTER ); }
		wp_schedule_event( time() + 60, 'p4all_pro_newsletter', Activator::CRON_HOOK_NEWSLETTER );
	}

	public static function tick() : void {
		if ( ! LicenseManager::is_feature_allowed() ) { return; }
		if ( ! class_exists( '\\Pay4All\\Form\\Domain\\Customer' ) ) { return; }

		$campaigns = Newsletter::pending_campaigns();
		if ( empty( $campaigns ) ) { return; }

		// One campaign per tick keeps the cron slice bounded — the next
		// tick picks up the next campaign, so a merchant queuing several
		// simultaneously still sees fair scheduling.
		$campaign_id = (int) $campaigns[0];
		self::process_batch( $campaign_id );
	}

	/**
	 * Processes one batch for the given campaign. Public so the admin
	 * page can trigger a manual « send now » that bypasses the cron
	 * (useful for testing the full pipeline without waiting 15 min).
	 */
	public static function process_batch( int $campaign_id ) : void {
		if ( $campaign_id <= 0 ) { return; }
		if ( ! class_exists( '\\Pay4All\\Form\\Domain\\Customer' ) ) { return; }

		Newsletter::set_status( $campaign_id, Newsletter::STATUS_SENDING );
		if ( ! get_post_meta( $campaign_id, Newsletter::META_STARTED_AT, true ) ) {
			update_post_meta( $campaign_id, Newsletter::META_STARTED_AT, gmdate( 'Y-m-d H:i:s' ) );
			update_post_meta( $campaign_id, Newsletter::META_AUDIENCE_TOTAL, \Pay4All\Form\Domain\Customer::count_active_recipients() );
		}

		$after = Newsletter::last_customer_id( $campaign_id );
		$batch = \Pay4All\Form\Domain\Customer::list_for_newsletter( $after, self::batch_size() );

		if ( empty( $batch ) ) {
			// Audience exhausted — mark done + freeze finished_at + notify
			// the site admin so they don't have to poll the campaign page
			// to know the broadcast completed.
			Newsletter::set_status( $campaign_id, Newsletter::STATUS_DONE );
			update_post_meta( $campaign_id, Newsletter::META_FINISHED_AT, gmdate( 'Y-m-d H:i:s' ) );
			self::notify_admin_done( $campaign_id );
			return;
		}

		$max_id = $after;
		foreach ( $batch as $row ) {
			$cid   = (int) ( $row['id']    ?? 0 );
			$email = (string) ( $row['email'] ?? '' );
			$max_id = max( $max_id, $cid );
			if ( $email === '' || ! is_email( $email ) ) { continue; }
			$lang = self::resolve_lang( (string) ( $row['lang'] ?? '' ) );
			self::send_one( $campaign_id, $row, $lang );
		}

		Newsletter::set_last_customer_id( $campaign_id, $max_id );

		// Peek right after the batch : if no more opted-in customer sits
		// past $max_id, flip the campaign to done on the SAME tick rather
		// than waiting for the next cron slice to notice. Matters most
		// when the audience is <= batch_size, i.e. the merchant hits
		// « Schedule broadcast » and sees the run finish instantly.
		$next = \Pay4All\Form\Domain\Customer::list_for_newsletter( $max_id, 1 );
		if ( empty( $next ) ) {
			Newsletter::set_status( $campaign_id, Newsletter::STATUS_DONE );
			update_post_meta( $campaign_id, Newsletter::META_FINISHED_AT, gmdate( 'Y-m-d H:i:s' ) );
			self::notify_admin_done( $campaign_id );
		}
	}

	/**
	 * Renders and dispatches a single campaign for a single customer.
	 * Errors are logged in the send-log table so the admin page can list
	 * them without swallowing the whole batch.
	 *
	 * Flow :
	 *   1. Insert the send_log row FIRST so we own a track_token BEFORE
	 *      the body is rendered. On duplicate (retry after crash) we get
	 *      the existing row's token back and reuse it — the tracking URLs
	 *      the recipient already has still resolve to their row.
	 *   2. Rewrite every http/https href in the body through a click-
	 *      tracker (except mailto:/tel:) and append a 1×1 open pixel.
	 *   3. wp_mail() the finished HTML.
	 *   4. On failure, downgrade the row's status to `failed` (or `test`
	 *      when the merchant is dry-running from the admin).
	 *
	 * @param array<string,mixed> $customer
	 */
	public static function send_one( int $campaign_id, array $customer, string $lang, bool $is_test = false ) : bool {
		$row = Newsletter::content_for_lang( $campaign_id, $lang );
		if ( trim( $row['subject'] ) === '' && trim( $row['body'] ) === '' ) { return false; }

		// Insert-or-fetch the send_log row FIRST so the tracking URLs we
		// embed in the body reference a persisted track_token. Tests use
		// customer_id 0 : we still want the log row (for the token) but
		// skip stats aggregation elsewhere via status='test'.
		$customer_id  = (int) ( $customer['id'] ?? 0 );
		$initial_status = $is_test ? NewsletterSendLog::STATUS_TEST : NewsletterSendLog::STATUS_SENT;
		[ , $send_id, $track_token ] = NewsletterSendLog::record(
			$campaign_id,
			$customer_id,
			(string) $customer['email'],
			$lang,
			$initial_status
		);
		if ( $track_token === '' ) {
			// Row is missing for reasons out of our control (DB error) —
			// keep the customer from being locked out : send WITHOUT tracking.
			$track_token = NewsletterSendLog::new_track_token();
		}

		$tokens  = NewsletterSubstitutor::tokens_for( $campaign_id, $customer, $lang, $track_token );
		$subject = NewsletterSubstitutor::replace( $row['subject'], $tokens );
		$body    = self::wrap_body(
			NewsletterSubstitutor::replace( $row['body'], $tokens ),
			NewsletterSubstitutor::replace( $row['cta_label'], $tokens ),
			NewsletterSubstitutor::replace( $row['cta_url'], $tokens ),
			$tokens,
			$track_token
		);

		$headers = [ 'Content-Type: text/html; charset=UTF-8' ];
		$ok = false;
		try {
			$ok = (bool) wp_mail( (string) $customer['email'], $subject, $body, $headers );
		} catch ( \Throwable $e ) {
			self::mark_send_status( $send_id, NewsletterSendLog::STATUS_FAILED, $e->getMessage() );
			return false;
		}
		if ( ! $ok && ! $is_test ) {
			self::mark_send_status( $send_id, NewsletterSendLog::STATUS_FAILED, '' );
		}
		return $ok;
	}

	/**
	 * Downgrades the send row's status without touching the tracking
	 * columns (they stay at their defaults so a delivery bounce doesn't
	 * accidentally count as an open).
	 */
	private static function mark_send_status( int $send_id, string $status, string $error ) : void {
		if ( $send_id <= 0 ) { return; }
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update( NewsletterSendLog::table(), [ 'status' => $status, 'error' => $error ], [ 'id' => $send_id ] );
	}

	/**
	 * Wraps the customer-typed body in a minimal outer HTML shell + CTA
	 * button, then adds the tracking layer :
	 *   - every http/https href inside the body OR the CTA gets rewritten
	 *     to route through the click tracker (except mailto:/tel:/# fragments
	 *     and the customer's unsubscribe URL, which we tag with the
	 *     track_token so the unsub endpoint can log the event too) ;
	 *   - a 1×1 transparent open-tracking pixel is appended at the end.
	 *
	 * @param array<string,string> $tokens
	 */
	private static function wrap_body( string $body_html, string $cta_label, string $cta_url, array $tokens, string $track_token ) : string {
		$cta_html = '';
		if ( $cta_label !== '' && $cta_url !== '' ) {
			$tracked_cta = self::rewrite_url_for_tracking( $cta_url, $track_token );
			$cta_html = '<p style="margin:24px 0;">'
				. '<a href="' . esc_url( $tracked_cta ) . '" '
				. 'style="display:inline-block;padding:12px 22px;background:#0a4b78;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">'
				. esc_html( $cta_label )
				. '</a></p>';
		}
		$tracked_body = self::rewrite_body_links( $body_html, $track_token );
		$pixel = '<img src="' . esc_url( self::open_pixel_url( $track_token ) ) . '" width="1" height="1" alt="" style="display:none;width:1px;height:1px;overflow:hidden;">';
		return '<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#222;line-height:1.55;max-width:640px;margin:auto;padding:24px;">'
			. $tracked_body
			. $cta_html
			. $pixel
			. '</div>';
	}

	/**
	 * Walks every `<a href="…">` in the body and swaps its target through
	 * self::rewrite_url_for_tracking(). Regex-based (no DOM parser) — the
	 * body comes from a wp_kses'd TinyMCE editor so anchors are always
	 * well-formed and quote characters are consistent.
	 */
	private static function rewrite_body_links( string $html, string $track_token ) : string {
		if ( $html === '' ) { return $html; }
		return (string) preg_replace_callback(
			'/<a\s+([^>]*?)href=(["\'])([^"\']+)(["\'])([^>]*)>/i',
			static function ( $m ) use ( $track_token ) {
				$before  = $m[1];
				$quote_o = $m[2];
				$url     = $m[3];
				$quote_c = $m[4];
				$after   = $m[5];
				$new_url = self::rewrite_url_for_tracking( $url, $track_token );
				return '<a ' . $before . 'href=' . $quote_o . esc_url( $new_url ) . $quote_c . $after . '>';
			},
			$html
		);
	}

	/**
	 * Decides how a single URL is transformed for tracking :
	 *   - `mailto:` / `tel:` / anchor fragments → left untouched ;
	 *   - unsubscribe URL (identified by the `p4all_nl_unsubscribe=` param)
	 *     → append the send's track_token as `t=…` so the endpoint can
	 *     credit the open + unsub against the right send row ;
	 *   - everything else (http / https / relative) → wrapped by the
	 *     click endpoint, original URL carried in base64 to survive URL
	 *     encoding on the redirect.
	 */
	private static function rewrite_url_for_tracking( string $url, string $track_token ) : string {
		$url = trim( $url );
		if ( $url === '' ) { return $url; }
		if ( strpos( $url, '#' ) === 0 ) { return $url; }
		$lower = strtolower( $url );
		if ( strpos( $lower, 'mailto:' ) === 0 || strpos( $lower, 'tel:' ) === 0 ) { return $url; }
		if ( strpos( $url, 'p4all_nl_unsubscribe=' ) !== false ) {
			// Preserve any existing query string, just append the tracking
			// token so the endpoint knows which send row to credit.
			return add_query_arg( [ 't' => $track_token ], $url );
		}
		return add_query_arg( [
			'p4all_nl_click' => $track_token,
			'u'              => rtrim( strtr( base64_encode( $url ), '+/', '-_' ), '=' ),
		], home_url( '/' ) );
	}

	/**
	 * URL of the open-tracking pixel embedded at the tail of every
	 * outgoing HTML e-mail. Served by TrackingEndpoint as a 1×1 GIF with
	 * no-cache headers.
	 */
	private static function open_pixel_url( string $track_token ) : string {
		return add_query_arg( [ 'p4all_nl_open' => $track_token ], home_url( '/' ) );
	}

	/**
	 * Picks the customer's stored language ; falls back to the free
	 * plugin's Language::current() (site locale).
	 */
	private static function resolve_lang( string $customer_lang ) : string {
		$customer_lang = strtolower( substr( trim( $customer_lang ), 0, 2 ) );
		if ( $customer_lang !== '' ) { return $customer_lang; }
		if ( class_exists( '\\Pay4All\\Form\\Domain\\Language' ) ) {
			return \Pay4All\Form\Domain\Language::current();
		}
		return 'en';
	}

	/**
	 * Sends a summary e-mail to the site admin_email when a campaign
	 * finishes broadcasting. Idempotent : we mark META_ADMIN_NOTIFIED
	 * right after so a retry can't fire a second notification.
	 *
	 * Rendering runs inside a switch_to_locale( get_locale() ) window so
	 * the notification is written in the SITE language rather than the
	 * cron-runner's fallback (empty locale on wp-cron), matching what
	 * the merchant sees in their admin.
	 */
	private static function notify_admin_done( int $campaign_id ) : void {
		if ( get_post_meta( $campaign_id, '_p4all_nl_admin_notified', true ) === '1' ) { return; }
		$post = get_post( $campaign_id );
		if ( ! $post ) { return; }

		$sent   = \Pay4All\Pro\Domain\NewsletterSendLog::count_sent( $campaign_id );
		$failed = \Pay4All\Pro\Domain\NewsletterSendLog::count_failed( $campaign_id );
		$total  = Newsletter::audience_total( $campaign_id );
		$title  = $post->post_title !== '' ? $post->post_title : '#' . $campaign_id;
		$edit   = admin_url( 'post.php?post=' . $campaign_id . '&action=edit' );
		$to     = (string) get_option( 'admin_email' );
		if ( ! $to || ! is_email( $to ) ) { return; }

		// switch_to_locale forces the site language into WP's gettext
		// stack for the duration of the render, then we restore. Guarded
		// with function_exists so a very old WP core still gets an
		// English notification (rare : the function ships since 4.7).
		$switched = false;
		if ( function_exists( 'switch_to_locale' ) ) {
			$switched = switch_to_locale( (string) get_locale() );
		}

		/* translators: %s = newsletter title */
		$subject = sprintf( __( '[%s] Newsletter broadcast complete', 'pay4all-pro' ), (string) get_bloginfo( 'name' ) );
		$body    = '<p>' . esc_html( sprintf(
			/* translators: %s = newsletter title */
			__( 'The « %s » campaign has finished broadcasting.', 'pay4all-pro' ),
			$title
		) ) . '</p>';
		$body   .= '<ul>';
		$body   .= '<li>' . esc_html( sprintf(
			/* translators: %d = number of successful sends */
			__( 'Successful sends : %d', 'pay4all-pro' ), $sent
		) ) . '</li>';
		if ( $failed > 0 ) {
			$body   .= '<li>' . esc_html( sprintf(
				/* translators: %d = number of failed sends */
				__( 'Failed sends : %d', 'pay4all-pro' ), $failed
			) ) . '</li>';
		}
		$body   .= '<li>' . esc_html( sprintf(
			/* translators: %d = audience size at start */
			__( 'Audience at start : %d', 'pay4all-pro' ), $total
		) ) . '</li>';
		$body   .= '</ul>';
		$body   .= '<p><a href="' . esc_url( $edit ) . '">' . esc_html__( 'Open the campaign', 'pay4all-pro' ) . '</a></p>';

		if ( $switched && function_exists( 'restore_previous_locale' ) ) {
			restore_previous_locale();
		}

		try {
			wp_mail( $to, $subject, $body, [ 'Content-Type: text/html; charset=UTF-8' ] );
		} catch ( \Throwable $e ) {
			// Silent : notification failures shouldn't gate the campaign flip.
		}
		update_post_meta( $campaign_id, '_p4all_nl_admin_notified', '1' );
	}

	private static function batch_size() : int {
		if ( ! class_exists( '\\Pay4All\\Form\\Admin\\Settings\\Schema' ) ) { return 20; }
		$v = (int) \Pay4All\Form\Admin\Settings\Schema::get( 'newsletter_batch_size', 20 );
		return $v > 0 ? $v : 20;
	}

	private static function batch_delay_minutes() : int {
		if ( ! class_exists( '\\Pay4All\\Form\\Admin\\Settings\\Schema' ) ) { return 15; }
		$v = (int) \Pay4All\Form\Admin\Settings\Schema::get( 'newsletter_batch_delay', 15 );
		return $v > 0 ? $v : 15;
	}
}
