<?php
/**
 * Cart Recovery — AI Composer
 *
 * Produces a personalized cart-recovery email from a composition context
 * (cart snapshot + chat transcript excerpt + stage). Reuses the plugin's
 * existing model router / auth manager / AI client wiring so the email
 * goes through the same frontend-lane LLM that powers the chat.
 *
 * Three guarantees:
 *   1. **Cache by content hash.** Identical `(stage, items_hash,
 *      transcript_hash, locale, has_coupon)` fingerprints share one
 *      cached composition — crucial for cost control when multiple
 *      visitors abandon similar carts.
 *   2. **Graceful fallback.** If any dependency is missing, the model
 *      call fails, or the response is malformed, the composer returns a
 *      templated fallback via `__()` so a mail still goes out rather
 *      than nothing. The fallback is marked `is_fallback: true`.
 *   3. **Coupon placeholder, not coupon code.** When `has_coupon = true`
 *      the composer is instructed to emit the literal string
 *      `{{COUPON_CODE}}` — the Phase 5 mailer interpolates the real
 *      per-recipient code at send time. This keeps unique codes out of
 *      the shared cache and eliminates any chance of an AI-hallucinated
 *      code leaking into mail.
 *
 * Does NOT send anything. Phase 5 mailer consumes the result.
 *
 * @package Niwa_AI
 * @since   0.12.0
 */

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

class Niwa_AI_Cart_Recovery_Composer {

	private const CACHE_PREFIX = 'niwa_ai_cr_compose_';
	private const CACHE_TTL    = 24 * HOUR_IN_SECONDS;

	public function __construct(
		private readonly ?Niwa_AI_Model_Router $model_router = null,
		private readonly ?Niwa_AI_Auth_Manager $auth_manager = null,
		private readonly ?Niwa_AI_AI_Client_Interface $ai_client = null,
		private readonly int $timeout_seconds = 20
	) {}

	/**
	 * Entry point — returns a composed (or fallback) email for this context.
	 */
	public function compose( Niwa_AI_Cart_Recovery_Composition_Context $ctx ): Niwa_AI_Cart_Recovery_Composition_Result {
		/**
		 * Filter the composition context before it drives the prompt.
		 * Return an instance of `Niwa_AI_Cart_Recovery_Composition_Context`
		 * to replace; non-DTO returns are ignored. Intended hook for
		 * A/B testing prompt variants or locale/region overrides.
		 */
		$filtered_ctx = apply_filters( 'niwa_ai_cart_recovery_compose_context', $ctx );
		if ( $filtered_ctx instanceof Niwa_AI_Cart_Recovery_Composition_Context ) {
			$ctx = $filtered_ctx;
		}

		if ( ! $ctx->is_valid() ) {
			return $this->fallback( $ctx );
		}

		// Phase 8: admin-authored template override. When the stage is
		// set to 'template' mode and the admin filled in at least the
		// subject AND one of (html|text), we skip the AI path entirely.
		$template_result = $this->try_admin_template( $ctx );
		if ( null !== $template_result ) {
			return $template_result;
		}

		$cache_key = $this->build_cache_key( $ctx );
		$cached    = $this->cache_get( $cache_key );
		if ( is_array( $cached ) ) {
			$cached['from_cache'] = true;
			return Niwa_AI_Cart_Recovery_Composition_Result::from_array( $cached );
		}

		if ( ! $this->model_router || ! $this->auth_manager || ! $this->ai_client ) {
			return $this->fallback( $ctx );
		}

		try {
			$model = (string) $this->model_router->route_for_frontend_chat();
			if ( '' === $model ) {
				return $this->fallback( $ctx );
			}

			$auth_mode = (string) $this->auth_manager->get_mode();
			$creds     = (array) $this->auth_manager->get_credentials();
			$messages  = $this->build_messages( $ctx );

			$response = $this->ai_client->chat_complete(
				$model,
				$messages,
				$auth_mode,
				$creds,
				$this->timeout_seconds,
				null,
				null
			);

			if ( ! is_array( $response ) || empty( $response['success'] ) ) {
				return $this->fallback( $ctx );
			}

			$raw    = isset( $response['answer'] ) ? (string) $response['answer'] : '';
			$parsed = $this->parse_response( $raw );
			if ( null === $parsed ) {
				return $this->fallback( $ctx );
			}

			$result = new Niwa_AI_Cart_Recovery_Composition_Result(
				(string) $parsed['subject'],
				(string) $parsed['preview_text'],
				(string) $parsed['html'],
				(string) $parsed['text'],
				false,
				$model,
				false
			);

			/**
			 * Filter the composed result before it's cached + returned.
			 * Intended hook for automated copy-editing layers (e.g.
			 * brand-voice linter, regional compliance strings).
			 * Return an instance of Composition_Result to replace.
			 */
			$filtered = apply_filters( 'niwa_ai_cart_recovery_compose_result', $result, $ctx );
			if ( $filtered instanceof Niwa_AI_Cart_Recovery_Composition_Result ) {
				$result = $filtered;
			}

			$this->cache_set( $cache_key, $result );

			return $result;
		} catch ( \Throwable $e ) {
			return $this->fallback( $ctx );
		}
	}

	/**
	 * Admin-authored template path. Returns a composed Result when the
	 * stage is in 'template' mode AND the admin supplied a subject +
	 * at least one body form (html or text). Returns null when the
	 * template path is not applicable — caller continues to AI path.
	 */
	protected function try_admin_template( Niwa_AI_Cart_Recovery_Composition_Context $ctx ): ?Niwa_AI_Cart_Recovery_Composition_Result {
		if ( ! class_exists( 'Niwa_AI_Cart_Recovery_Settings' ) ) {
			return null;
		}
		if ( ! Niwa_AI_Cart_Recovery_Settings::is_template_mode_for_stage( $ctx->stage ) ) {
			return null;
		}

		$template = Niwa_AI_Cart_Recovery_Settings::get_stage_template_for_locale( $ctx->stage, $ctx->locale );
		$subject  = (string) ( $template['subject'] ?? '' );
		$html     = (string) ( $template['html'] ?? '' );
		$text     = (string) ( $template['text'] ?? '' );

		// If both admin and built-in templates are somehow empty, fall
		// through so the AI path still produces something sendable
		// instead of blank-subject mail.
		if ( '' === trim( $subject ) || ( '' === trim( $html ) && '' === trim( $text ) ) ) {
			return null;
		}

		// Coupon code is still a runtime concern — keep {{COUPON_CODE}}
		// compatibility alongside the renderer's `{coupon_code}` token so
		// admins can use either (or both) forms.
		$coupon_placeholder = $ctx->has_coupon ? '{{COUPON_CODE}}' : '';

		$expanded = Niwa_AI_Cart_Recovery_Placeholder_Renderer::expand_stage_template(
			$template,
			$ctx,
			$coupon_placeholder
		);

		// When only html OR only text was filled, auto-derive the
		// missing form so downstream mailer + wrap logic still works.
		if ( '' === trim( $expanded['html'] ) && '' !== trim( $expanded['text'] ) ) {
			$expanded['html'] = '<p>' . nl2br( $this->h( $expanded['text'] ) ) . '</p>';
		}
		if ( '' === trim( $expanded['text'] ) && '' !== trim( $expanded['html'] ) ) {
			// Preserve list/block structure by inserting newlines around
			// block-level boundaries BEFORE stripping tags. Otherwise
			// `<li>A</li><li>B</li>` collapses to "AB", destroying cart
			// item readability in the plain-text variant.
			$normalized = $expanded['html'];
			$normalized = preg_replace( '/<li[^>]*>/i', '- ', $normalized );
			$normalized = preg_replace( '/<\/(?:li|p|h[1-6]|div|br\s*\/?|ul|ol)>/i', "\n", $normalized );
			$normalized = preg_replace( '/<br\s*\/?>/i', "\n", $normalized );
			$text       = wp_strip_all_tags( (string) $normalized );
			// Collapse 3+ blank lines, trim trailing whitespace per line.
			$text       = preg_replace( "/[ \t]+\n/", "\n", $text );
			$text       = preg_replace( "/\n{3,}/", "\n\n", $text );
			$expanded['text'] = trim( (string) $text );
		}

		return new Niwa_AI_Cart_Recovery_Composition_Result(
			$expanded['subject'],
			mb_substr( trim( wp_strip_all_tags( $expanded['html'] ) ), 0, 90 ),
			$expanded['html'],
			$expanded['text'],
			false,
			'admin_template',
			false
		);
	}

	private function h( string $s ): string {
		if ( function_exists( 'esc_html' ) ) {
			return esc_html( $s );
		}
		return htmlspecialchars( $s, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
	}

	/**
	 * Deterministic cache key over every signal that influences the copy.
	 * Coupon code is deliberately excluded — only `has_coupon` bit
	 * matters, since the code itself is a placeholder in cached output.
	 */
	public function build_cache_key( Niwa_AI_Cart_Recovery_Composition_Context $ctx ): string {
		$signature = implode(
			'|',
			array(
				$ctx->stage,
				$ctx->snapshot->items_hash,
				md5( $ctx->transcript_excerpt ),
				$ctx->locale,
				$ctx->has_coupon ? '1' : '0',
			)
		);
		return self::CACHE_PREFIX . substr( hash( 'sha256', $signature ), 0, 40 );
	}

	/**
	 * Build the chat-completion messages array. System prompt carries
	 * the persona + hard constraints; user prompt carries the per-row
	 * structured data.
	 *
	 * @return array<int, array<string, string>>
	 */
	protected function build_messages( Niwa_AI_Cart_Recovery_Composition_Context $ctx ): array {
		return array(
			array(
				'role'    => 'system',
				'content' => $this->build_system_prompt( $ctx ),
			),
			array(
				'role'    => 'user',
				'content' => $this->build_user_prompt( $ctx ),
			),
		);
	}

	protected function build_system_prompt( Niwa_AI_Cart_Recovery_Composition_Context $ctx ): string {
		$stage_hint = Niwa_AI_Cart_Recovery_Composition_Context::STAGE_MAIL1 === $ctx->stage
			? 'This is the FIRST reminder (~15 minutes after they left). Keep it light and helpful — no discounts, no urgency tactics, just a warm nudge.'
			: ( $ctx->has_coupon
				? 'This is the SECOND reminder (~24 hours after the first). Slightly warmer tone, acknowledge they probably got busy, and reference the coupon placeholder exactly as {{COUPON_CODE}}. DO NOT invent a coupon code.'
				: 'This is the SECOND reminder (~24 hours after the first). Slightly warmer tone, acknowledge they probably got busy, and do not mention a discount or coupon.' );

		return implode(
			"\n",
			array(
				'You are "Niva", a friendly shopping assistant writing a cart-recovery reminder for the store "' . $ctx->store_name . '".',
				'Write the reminder entirely in the language of locale: ' . $ctx->locale . '. If the locale is Serbian (sr_*), write natural, warm, casual Serbian — never robotic.',
				'Persona: you are NOT a marketing bot. You are a helpful assistant who remembers talking to them. Short sentences. First-person ("ja", "I"). No ALL CAPS, no exclamation spam, no emoji flood.',
				$stage_hint,
				'HARD RULES:',
				'- Reference cart items by the exact names provided. Do NOT invent names, prices, or product attributes.',
				'- Do NOT generate coupon codes. If a coupon placeholder is required, use the literal string {{COUPON_CODE}} — the mailer will interpolate the real code.',
				'- Subject line: under 60 characters.',
				'- Preview text: under 90 characters.',
				'- HTML body: simple inline markup only (<p>, <strong>, <em>, <a>, <br>). No CSS, no images, no tables. Under 250 words.',
				'- Plain text body: same content as HTML without markup. Under 200 words.',
				'- If no customer name is provided, use a generic greeting appropriate for the locale (e.g. "Pozdrav" for Serbian).',
				'OUTPUT FORMAT: Return ONLY a single JSON object with exactly these four keys: "subject", "preview_text", "html", "text". No prose before or after. No code fences. No markdown. Pure JSON.',
			)
		);
	}

	protected function build_user_prompt( Niwa_AI_Cart_Recovery_Composition_Context $ctx ): string {
		$lines   = array();
		$lines[] = 'STAGE: ' . $ctx->stage;
		$lines[] = 'LOCALE: ' . $ctx->locale;
		$lines[] = 'STORE_NAME: ' . $ctx->store_name;
		$lines[] = 'CUSTOMER_NAME: ' . ( '' !== $ctx->customer_name ? $ctx->customer_name : 'NONE' );
		$lines[] = 'HAS_COUPON: ' . ( $ctx->has_coupon ? 'true' : 'false' );
		if ( $ctx->has_coupon ) {
			$lines[] = 'COUPON_TTL_HOURS: ' . $ctx->coupon_ttl_hours;
		}
		$lines[] = 'CART_ITEMS:';
		foreach ( $ctx->snapshot->items as $item ) {
			$lines[] = sprintf(
				'- %s x%d @ %s',
				$item->name,
				$item->qty,
				number_format( $item->unit_price, 2, '.', '' )
			);
		}
		$lines[] = 'CART_CURRENCY: ' . $ctx->snapshot->currency;
		$lines[] = 'CART_TOTAL: ' . number_format( $ctx->snapshot->total, 2, '.', '' );
		$lines[] = 'TRANSCRIPT_EXCERPT:';
		$lines[] = '' !== trim( $ctx->transcript_excerpt ) ? $ctx->transcript_excerpt : '(no conversation excerpt)';
		$lines[] = '';
		$lines[] = 'Output the JSON object now.';

		return implode( "\n", $lines );
	}

	/**
	 * Strict JSON extraction. Accepts either raw JSON or JSON wrapped in
	 * a markdown code fence (```json ... ```) since some models emit
	 * fences despite being told not to. Returns null when any required
	 * field is missing or empty — caller falls back to template.
	 *
	 * @return array<string, string>|null
	 */
	public function parse_response( string $raw ): ?array {
		$raw = trim( $raw );
		if ( '' === $raw ) {
			return null;
		}

		// Strip markdown fences when the model ignores instructions.
		if ( preg_match( '/```(?:json)?\s*(\{.*\})\s*```/is', $raw, $m ) ) {
			$raw = $m[1];
		}

		// Some models prefix "Here is the JSON:" — cut to first `{`.
		$first_brace = strpos( $raw, '{' );
		$last_brace  = strrpos( $raw, '}' );
		if ( false === $first_brace || false === $last_brace || $last_brace <= $first_brace ) {
			return null;
		}
		$raw = substr( $raw, $first_brace, ( $last_brace - $first_brace ) + 1 );

		$decoded = json_decode( $raw, true );
		if ( ! is_array( $decoded ) ) {
			return null;
		}

		$required = array( 'subject', 'preview_text', 'html', 'text' );
		foreach ( $required as $field ) {
			if ( ! isset( $decoded[ $field ] ) || ! is_string( $decoded[ $field ] ) || '' === trim( $decoded[ $field ] ) ) {
				return null;
			}
		}

		return array(
			'subject'      => $decoded['subject'],
			'preview_text' => $decoded['preview_text'],
			'html'         => $decoded['html'],
			'text'         => $decoded['text'],
		);
	}

	/**
	 * Templated fallback — locale-sensitive via `__()` but with sane
	 * defaults so a plain install still produces reasonable copy. The
	 * fallback embeds `{{COUPON_CODE}}` when has_coupon is true, same
	 * contract as the AI path.
	 */
	protected function fallback( Niwa_AI_Cart_Recovery_Composition_Context $ctx ): Niwa_AI_Cart_Recovery_Composition_Result {
		$name    = '' !== $ctx->customer_name ? $ctx->customer_name : '';
		$hello   = '' !== $name
			? sprintf( /* translators: %s = customer first name */ __( 'Pozdrav %s,', 'niwa-ai' ), $name )
			: __( 'Pozdrav,', 'niwa-ai' );
		$items   = array();
		foreach ( $ctx->snapshot->items as $item ) {
			$items[] = sprintf( '%s × %d', $item->name, $item->qty );
		}
		$items_list = implode( ', ', $items );

		if ( Niwa_AI_Cart_Recovery_Composition_Context::STAGE_MAIL1 === $ctx->stage ) {
			$subject      = __( 'Zaboravili ste nešto u korpi', 'niwa-ai' );
			$preview      = __( 'Ostavili ste artikle u korpi — nismo zaboravili.', 'niwa-ai' );
			$body_tpl_txt = __( "%1\$s\n\nNiva ovde iz %2\$s. Primetila sam da ste imali ovo u korpi: %3\$s. Nismo sačuvali korpu zauvek, pa ako vam još treba — samo dođite nazad da završite.\n\nAko vam nešto ne štima ili imate pitanje, odgovorite na ovu poruku.\n\nPoz,\nNiva", 'niwa-ai' );
			$body_tpl_html = '<p>%1$s</p><p>' . __( 'Niva ovde iz %2$s. Primetila sam da ste imali ovo u korpi: %3$s. Nismo sačuvali korpu zauvek, pa ako vam još treba — samo dođite nazad da završite.', 'niwa-ai' ) . '</p><p>' . __( 'Ako vam nešto ne štima ili imate pitanje, odgovorite na ovu poruku.', 'niwa-ai' ) . '</p><p>' . __( 'Poz,<br>Niva', 'niwa-ai' ) . '</p>';
		} else {
			$subject = __( 'Mali poklon za vas — i korpa vas čeka', 'niwa-ai' );
			$preview = __( 'Kod za popust koji sam krišom izvukla.', 'niwa-ai' );
			if ( $ctx->has_coupon ) {
				$body_tpl_txt  = __( "%1\$s\n\nNiva ovde. Videla sam da niste završili kupovinu, pa sam vam krišom izvukla mali kod za popust: {{COUPON_CODE}}. Možete ga iskoristiti na checkout stranici.\n\nKorpa: %3\$s\n\nAko nešto ne ide kako treba, odgovorite na ovu poruku.\n\nPoz,\nNiva", 'niwa-ai' );
				$body_tpl_html = '<p>%1$s</p><p>' . __( 'Niva ovde. Videla sam da niste završili kupovinu, pa sam vam krišom izvukla mali kod za popust: <strong>{{COUPON_CODE}}</strong>. Možete ga iskoristiti na checkout stranici.', 'niwa-ai' ) . '</p><p>' . __( 'Korpa: %3$s', 'niwa-ai' ) . '</p><p>' . __( 'Ako nešto ne ide kako treba, odgovorite na ovu poruku.', 'niwa-ai' ) . '</p><p>' . __( 'Poz,<br>Niva', 'niwa-ai' ) . '</p>';
			} else {
				$body_tpl_txt  = __( "%1\$s\n\nNiva ovde. Videla sam da niste završili kupovinu, pa samo javljam da je korpa još uvek tu: %3\$s. Ako vam nešto ne ide — pitajte slobodno.\n\nPoz,\nNiva", 'niwa-ai' );
				$body_tpl_html = '<p>%1$s</p><p>' . __( 'Niva ovde. Videla sam da niste završili kupovinu, pa samo javljam da je korpa još uvek tu: %3$s. Ako vam nešto ne ide — pitajte slobodno.', 'niwa-ai' ) . '</p><p>' . __( 'Poz,<br>Niva', 'niwa-ai' ) . '</p>';
			}
		}

		$text = sprintf( $body_tpl_txt, $hello, $ctx->store_name, $items_list );
		$html = sprintf( $body_tpl_html, esc_html( $hello ), esc_html( $ctx->store_name ), esc_html( $items_list ) );

		return new Niwa_AI_Cart_Recovery_Composition_Result(
			$subject,
			$preview,
			$html,
			$text,
			false,
			'',
			true
		);
	}

	protected function cache_get( string $key ): ?array {
		if ( ! class_exists( 'Niwa_AI_Durable_Cache' ) ) {
			return null;
		}
		$value = Niwa_AI_Durable_Cache::get( $key );
		return is_array( $value ) ? $value : null;
	}

	protected function cache_set( string $key, Niwa_AI_Cart_Recovery_Composition_Result $result ): void {
		if ( ! class_exists( 'Niwa_AI_Durable_Cache' ) ) {
			return;
		}
		Niwa_AI_Durable_Cache::set( $key, $result->to_array(), self::CACHE_TTL );
	}
}
