<?php
/**
 * Cart Recovery — Coupon Service
 *
 * Creates, dedupes, attaches, and cleans up WooCommerce coupons for
 * the cart-recovery flow. Designed so the Phase 5 mailer can simply
 * ask `get_or_create_coupon_for_row($row)` and get back a `code`
 * string to interpolate into `{{COUPON_CODE}}` — all policy (type,
 * amount, TTL, restrictions, cleanup) lives here.
 *
 * Full-feature highlights:
 *   - Dedupe: rows that already carry a non-expired coupon reuse it;
 *     regeneration only happens on actual expiry.
 *   - Email restriction when an email is available. Phone-only WhatsApp
 *     rows remain protected by a random code and global/per-user use limit 1.
 *   - Usage limit 1 + per-user limit 1 + individual_use by default.
 *   - Optional restrict_to_cart_products → `product_ids` from snapshot.
 *   - Optional minimum_spend = cart subtotal at time of capture.
 *   - Optional exclude_sale_items + free_shipping.
 *   - Cleanup: old WC_Coupon posts past expiry + grace window are
 *     trashed in batches via the scheduler's tick.
 *   - Filter hooks: admin can tweak code prefix, full config per row,
 *     and final post data before WC saves.
 *   - No-WC graceful: every method returns a null/empty/false and
 *     logs when `WP_DEBUG`, never throws into the caller.
 *
 * @package Niwa_AI
 * @since   0.12.0
 */

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

class Niwa_AI_Cart_Recovery_Coupon_Service {

	/** Safety ceiling so a broken uniqueness check can't infinite-loop. */
	private const MAX_UNIQUE_TRIES = 20;

	public function __construct(
		private readonly Niwa_AI_Cart_Recovery_Repository $repository
	) {}

	/**
	 * Main entry. Given a recovery row and the active coupon config,
	 * returns `['code' => ..., 'expires_at' => ..., 'reused' => bool]`
	 * or `null` if we can't (or shouldn't) create one.
	 *
	 * Reuse contract: if the row already carries a non-expired coupon
	 * and the WC post is still present, we return it verbatim. This
	 * matters because Phase 2's scheduler can fire the mail2 send
	 * action more than once on network hiccups — we must not mint a
	 * new code each time.
	 *
	 * @param array<string, mixed>                     $row    Recovery row (from repository).
	 * @param Niwa_AI_Cart_Recovery_Coupon_Config|null $config Optional override; falls back to settings.
	 * @return array<string, mixed>|null
	 */
	public function get_or_create_coupon_for_row( array $row, ?Niwa_AI_Cart_Recovery_Coupon_Config $config = null ): ?array {
		if ( ! $this->is_wc_available() ) {
			$this->log_failure( 'get_or_create', 'WC not available' );
			return null;
		}

		$id    = (int) ( $row['id'] ?? 0 );
		$email = trim( (string) ( $row['email'] ?? '' ) );
		if ( $id <= 0 ) {
			return null;
		}

		$config = $config ?? Niwa_AI_Cart_Recovery_Settings::get_coupon_config();

		// 1. Reuse path — existing valid coupon on the row.
		$existing_code    = (string) ( $row['coupon_code'] ?? '' );
		$existing_expires = (string) ( $row['coupon_expires_at'] ?? '' );
		if ( '' !== $existing_code && $this->is_still_valid( $existing_code, $existing_expires ) ) {
			return array(
				'code'       => $existing_code,
				'expires_at' => $existing_expires,
				'reused'     => true,
			);
		}

		// 2. Reconstruct snapshot DTO from row JSON (for product restriction / minimum spend).
		$snapshot = null;
		if ( ! empty( $row['cart_snapshot_json'] ) && is_string( $row['cart_snapshot_json'] ) ) {
			$snapshot = Niwa_AI_Cart_Recovery_Cart_Snapshot::from_json( $row['cart_snapshot_json'] );
		}

		// 3. Generate unique code.
		$prefix = (string) apply_filters( 'niwa_ai_cart_recovery_coupon_code_prefix', $config->prefix, $row, $config );
		$code   = $this->generate_unique_code( $prefix );
		if ( '' === $code ) {
			$this->log_failure( 'get_or_create', 'unique code generation failed' );
			return null;
		}

		// 4. Create the WC_Coupon post.
		$expires_ts      = time() + ( $config->ttl_hours * HOUR_IN_SECONDS );
		$expires_at_sql  = gmdate( 'Y-m-d H:i:s', $expires_ts );
		$post_id         = $this->create_wc_coupon( $code, $config, $email, $snapshot, $expires_ts, $row );
		if ( $post_id <= 0 ) {
			$this->log_failure( 'get_or_create', 'WC_Coupon save returned 0' );
			return null;
		}

		// 5. Persist on the row.
		$this->repository->attach_coupon( $id, $code, $expires_at_sql );

		/**
		 * Fires after a cart-recovery coupon is successfully generated
		 * and attached to a row. Listeners can tag the coupon, inject
		 * notes, or build analytics events.
		 */
		do_action( 'niwa_ai_cart_recovery_coupon_generated', $code, $id, $post_id, $config );

		return array(
			'code'       => $code,
			'expires_at' => $expires_at_sql,
			'reused'     => false,
		);
	}

	/**
	 * Whether `$code` is a cart-recovery coupon that still has life.
	 * Combines DB expiry timestamp with a lookup against the WC post —
	 * if someone manually trashed the post, we treat it as expired so
	 * the mailer regenerates.
	 */
	public function is_still_valid( string $code, string $expires_at_mysql ): bool {
		$code = trim( $code );
		if ( '' === $code || '' === $expires_at_mysql ) {
			return false;
		}

		$expires_ts = strtotime( $expires_at_mysql );
		if ( false === $expires_ts || $expires_ts <= time() ) {
			return false;
		}

		if ( ! $this->is_wc_available() ) {
			return false;
		}

		return $this->wc_coupon_post_exists( $code );
	}

	/**
	 * Trash WC_Coupon posts for recovery rows whose coupons expired
	 * more than `$grace_days` ago. Runs from the scheduler tick.
	 *
	 * @return int Number of coupon posts trashed.
	 */
	public function cleanup_expired_coupons( int $grace_days, int $limit = 100 ): int {
		if ( ! $this->is_wc_available() ) {
			return 0;
		}

		$rows    = $this->repository->get_rows_with_expired_coupons( $grace_days, $limit );
		$trashed = 0;

		foreach ( $rows as $row ) {
			$id   = (int) ( $row['id'] ?? 0 );
			$code = (string) ( $row['coupon_code'] ?? '' );
			if ( $id <= 0 || '' === $code ) {
				continue;
			}

			$post_id = $this->lookup_wc_coupon_post_id( $code );
			if ( $post_id > 0 ) {
				$this->trash_wc_coupon_post( $post_id );
				$trashed++;
			}

			// Clear the row reference regardless — the recovery row should
			// no longer advertise a trashed / missing coupon.
			$this->repository->clear_coupon( $id );
		}

		return $trashed;
	}

	/* ===============================================================
	 * Internal — extension points (protected for test overrides)
	 * ============================================================ */

	/**
	 * Generate a unique `PREFIX + RANDOM` code that doesn't collide
	 * with an existing WC_Coupon. Returns empty string when the
	 * uniqueness check repeatedly fails (unlikely but bounded).
	 */
	protected function generate_unique_code( string $prefix ): string {
		for ( $attempt = 0; $attempt < self::MAX_UNIQUE_TRIES; $attempt++ ) {
			$suffix    = $this->random_suffix( 8 );
			$candidate = strtoupper( $prefix . $suffix );
			if ( ! $this->wc_coupon_post_exists( $candidate ) ) {
				return $candidate;
			}
		}
		return '';
	}

	/**
	 * Build + save a WC_Coupon. Returns the post id on success, 0 on
	 * any failure. Applies `niwa_ai_cart_recovery_coupon_post_create`
	 * filter on the data array so extenders can override any field.
	 */
	protected function create_wc_coupon(
		string $code,
		Niwa_AI_Cart_Recovery_Coupon_Config $config,
		string $email,
		?Niwa_AI_Cart_Recovery_Cart_Snapshot $snapshot,
		int $expires_ts,
		array $row
	): int {
		if ( ! class_exists( 'WC_Coupon' ) ) {
			return 0;
		}

		try {
			$coupon = new WC_Coupon();
			$coupon->set_code( $code );
			$coupon->set_discount_type( $config->discount_type );
			$coupon->set_amount( $config->amount );
			$coupon->set_date_expires( $expires_ts );
			$coupon->set_usage_limit( 1 );
			$coupon->set_usage_limit_per_user( 1 );
			$coupon->set_individual_use( $config->individual_use );
			$coupon->set_exclude_sale_items( $config->exclude_sale_items );
			$coupon->set_free_shipping( $config->free_shipping );
			if ( '' !== $email ) {
				$coupon->set_email_restrictions( array( $email ) );
			}
			$coupon->set_description(
				'' !== $email
					? sprintf(
						/* translators: %s = recipient email. */
						__( 'Cart recovery coupon — Niwa AI (issued for %s).', 'niwa-ai' ),
						$email
					)
					: __( 'Cart recovery coupon — Niwa AI (WhatsApp reminder).', 'niwa-ai' )
			);

			// Product restriction.
			if ( $config->restrict_to_cart_products && $snapshot && ! $snapshot->is_empty() ) {
				$product_ids = array();
				foreach ( $snapshot->items as $item ) {
					$pid = $item->variation_id > 0 ? $item->variation_id : $item->product_id;
					if ( $pid > 0 ) {
						$product_ids[] = $pid;
					}
				}
				if ( ! empty( $product_ids ) ) {
					$coupon->set_product_ids( array_values( array_unique( $product_ids ) ) );
				}
			}

			// Minimum spend.
			if (
				Niwa_AI_Cart_Recovery_Coupon_Config::MIN_SPEND_CART_TOTAL === $config->minimum_spend_mode
				&& $snapshot
				&& $snapshot->subtotal > 0
			) {
				$coupon->set_minimum_amount( (string) $snapshot->subtotal );
			}

			$filterable = apply_filters(
				'niwa_ai_cart_recovery_coupon_post_create',
				array(
					'coupon'   => $coupon,
					'code'     => $code,
					'config'   => $config,
					'email'    => $email,
					'snapshot' => $snapshot,
					'row'      => $row,
				)
			);

			if ( is_array( $filterable ) && isset( $filterable['coupon'] ) && $filterable['coupon'] instanceof WC_Coupon ) {
				$coupon = $filterable['coupon'];
			}

			$post_id = $coupon->save();
			return is_numeric( $post_id ) ? (int) $post_id : 0;
		} catch ( \Throwable $e ) {
			$this->log_failure( 'create_wc_coupon', $e->getMessage() );
			return 0;
		}
	}

	protected function random_suffix( int $length = 8 ): string {
		$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I — visual clarity.
		$out      = '';
		$max      = strlen( $alphabet ) - 1;
		for ( $i = 0; $i < $length; $i++ ) {
			try {
				$pick = random_int( 0, $max );
			} catch ( \Throwable $e ) {
				$pick = mt_rand( 0, $max );
			}
			$out .= $alphabet[ $pick ];
		}
		return $out;
	}

	/**
	 * Whether a WC_Coupon with this code already exists. Protected so
	 * tests can stub without touching WC.
	 */
	protected function wc_coupon_post_exists( string $code ): bool {
		return $this->lookup_wc_coupon_post_id( $code ) > 0;
	}

	/**
	 * Look up the post id for a coupon code, returning 0 when absent.
	 * Uses WC's helper when available, falls back to direct `get_page_by_title`.
	 */
	protected function lookup_wc_coupon_post_id( string $code ): int {
		$code = trim( $code );
		if ( '' === $code ) {
			return 0;
		}

		if ( function_exists( 'wc_get_coupon_id_by_code' ) ) {
			$id = (int) wc_get_coupon_id_by_code( $code );
			if ( $id > 0 ) {
				return $id;
			}
		}

		if ( function_exists( 'get_page_by_title' ) ) {
			// phpcs:ignore WordPress.WP.DeprecatedFunctions.get_page_by_titleFound
			$page = get_page_by_title( $code, OBJECT, 'shop_coupon' );
			if ( is_object( $page ) && isset( $page->ID ) ) {
				return (int) $page->ID;
			}
		}

		return 0;
	}

	protected function trash_wc_coupon_post( int $post_id ): void {
		if ( function_exists( 'wp_trash_post' ) && $post_id > 0 ) {
			wp_trash_post( $post_id );
		}
	}

	protected function is_wc_available(): bool {
		return class_exists( 'WC_Coupon' ) && function_exists( 'WC' );
	}

	private function log_failure( string $label, string $message ): void {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( sprintf( '[niwa-ai][cart-recovery][coupon.%s] %s', $label, $message ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}
	}
}
