<?php
/**
 * Lead Form Generator — Runtime State Machine
 *
 * Drives a single visitor's lead-form flow: starting a session, validating
 * incoming answers, deciding the next question (deterministic rules → LLM
 * router → document order), persisting progress, and emitting optional
 * side-effects (Contact_Service capture, notification mailer).
 *
 * @package Niwa_AI
 * @since   0.12.0
 */

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

class Niwa_AI_Lead_Form_Runtime {

	/**
	 * @var Niwa_AI_Lead_Form_Session_Store
	 */
	private $store;

	/**
	 * @var Niwa_AI_Lead_Form_LLM_Router|null
	 */
	private $llm_router;

	/**
	 * @var Niwa_AI_Contact_Service
	 */
	private $contact_service;

	/**
	 * @var Niwa_AI_Lead_Form_Mailer|null
	 */
	private $mailer;

	/**
	 * Constructor.
	 *
	 * @param Niwa_AI_Lead_Form_Session_Store $store           Session store.
	 * @param Niwa_AI_Lead_Form_LLM_Router|null $llm_router    LLM router (nullable).
	 * @param Niwa_AI_Contact_Service         $contact_service Contact service.
	 * @param Niwa_AI_Lead_Form_Mailer|null   $mailer          Optional mailer.
	 */
	public function __construct( $store, $llm_router, $contact_service, $mailer = null ) {
		$this->store           = $store;
		$this->llm_router      = $llm_router;
		$this->contact_service = $contact_service;
		$this->mailer          = $mailer;
	}

	/**
	 * Inject/replace the mailer after construction.
	 *
	 * @param Niwa_AI_Lead_Form_Mailer|null $mailer Mailer.
	 * @return void
	 */
	public function set_mailer( $mailer ) {
		$this->mailer = $mailer;
	}

	/**
	 * Start (or resume) a flow for the given public session id.
	 *
	 * @param string      $session_id    Public session id.
	 * @param string|null $mode_override Optional 'chat'|'card' override.
	 * @return array
	 */
	public function start( $session_id, $mode_override = null ) {
		$schema = Niwa_AI_Lead_Form_Schema::get();

		if ( empty( $schema['enabled'] ) ) {
			return $this->completed_payload( 0, $schema );
		}

		$total = Niwa_AI_Lead_Form_Schema::total_questions( $schema );
		if ( $total <= 0 ) {
			return $this->completed_payload( 0, $schema );
		}

		$mode = isset( $schema['mode'] ) ? (string) $schema['mode'] : Niwa_AI_Lead_Form_Schema::MODE_CHAT;
		if ( null !== $mode_override && in_array( (string) $mode_override, array( Niwa_AI_Lead_Form_Schema::MODE_CHAT, Niwa_AI_Lead_Form_Schema::MODE_CARD ), true ) ) {
			$mode = (string) $mode_override;
		}

		// Resume vs. start.
		$existing = $this->store->find_by_session_id( $session_id );
		if ( $existing ) {
			$db_id      = (int) $existing['id'];
			$current_id = isset( $existing['current_question_id'] ) ? (string) $existing['current_question_id'] : '';
			$status     = isset( $existing['status'] ) ? (string) $existing['status'] : Niwa_AI_Lead_Form_Session_Store::STATUS_ACTIVE;

			if ( Niwa_AI_Lead_Form_Session_Store::STATUS_COMPLETED === $status ) {
				return $this->completed_payload( $db_id, $schema );
			}

			$index = Niwa_AI_Lead_Form_Schema::question_index( $schema );
			if ( '' === $current_id || ! isset( $index[ $current_id ] ) ) {
				// Schema drift — seed from first.
				$current_id = (string) Niwa_AI_Lead_Form_Schema::first_question_id( $schema );
				if ( '' !== $current_id ) {
					$this->store->update_progress( $db_id, $current_id, Niwa_AI_Lead_Form_Session_Store::STATUS_ACTIVE );
				}
			}

			$question = isset( $index[ $current_id ]['question'] ) ? $index[ $current_id ]['question'] : null;
			return array(
				'session_row_id' => $db_id,
				'mode'           => $mode,
				'question'       => $question ? $this->render_question_for_client( $question ) : null,
				'progress'       => $this->progress_for( $db_id, $schema, false, $current_id ),
				'completed'      => null === $question,
			);
		}

		$first_qid = (string) Niwa_AI_Lead_Form_Schema::first_question_id( $schema );
		if ( '' === $first_qid ) {
			return $this->completed_payload( 0, $schema );
		}

		$db_id = $this->store->start_session( $session_id, $mode, $first_qid );
		if ( null === $db_id ) {
			return array(
				'session_row_id' => 0,
				'mode'           => $mode,
				'question'       => null,
				'progress'       => array( 'current' => 0, 'total' => $total ),
				'completed'      => false,
				'error'          => __( 'Neuspešno pokretanje sesije.', 'niwa-ai' ),
			);
		}

		$index    = Niwa_AI_Lead_Form_Schema::question_index( $schema );
		$question = isset( $index[ $first_qid ]['question'] ) ? $index[ $first_qid ]['question'] : null;

		return array(
			'session_row_id' => $db_id,
			'mode'           => $mode,
			'question'       => $question ? $this->render_question_for_client( $question ) : null,
			'progress'       => $this->progress_for( $db_id, $schema, false, $first_qid ),
			'completed'      => false,
		);
	}

	/**
	 * Process an answer submission.
	 *
	 * @param string $session_id  Public session id.
	 * @param string $question_id Question id that was answered.
	 * @param mixed  $answer      Answer (string or array).
	 * @return array
	 */
	public function submit_answer( $session_id, $question_id, $answer ) {
		$schema = Niwa_AI_Lead_Form_Schema::get();
		$index  = Niwa_AI_Lead_Form_Schema::question_index( $schema );

		$row = $this->store->find_by_session_id( $session_id );
		if ( ! $row ) {
			return array(
				'error'     => __( 'Sesija ne postoji. Osvežite stranicu i pokušajte ponovo.', 'niwa-ai' ),
				'question'  => null,
				'completed' => false,
				'progress'  => array( 'current' => 0, 'total' => Niwa_AI_Lead_Form_Schema::total_questions( $schema ) ),
			);
		}
		$db_id = (int) $row['id'];

		if ( ! isset( $index[ $question_id ] ) ) {
			$current_id = isset( $row['current_question_id'] ) ? (string) $row['current_question_id'] : '';
			$current_q  = isset( $index[ $current_id ]['question'] ) ? $index[ $current_id ]['question'] : null;
			return array(
				'error'     => __( 'Nepoznato pitanje.', 'niwa-ai' ),
				'question'  => $current_q ? $this->render_question_for_client( $current_q ) : null,
				'completed' => false,
				'progress'  => $this->progress_for( $db_id, $schema, false, $current_id ),
			);
		}

		$entry    = $index[ $question_id ];
		$question = $entry['question'];
		$step_id  = '';
		if ( isset( $schema['steps'][ $entry['step_index'] ]['id'] ) ) {
			$step_id = (string) $schema['steps'][ $entry['step_index'] ]['id'];
		}

		// Validate.
		$validation_error = $this->validate_answer( $question, $answer );
		if ( null !== $validation_error ) {
			return array(
				'error'     => $validation_error,
				'question'  => $this->render_question_for_client( $question ),
				'completed' => false,
				'progress'  => $this->progress_for( $db_id, $schema, false, $question_id ),
			);
		}

		// Persist answer.
		$this->store->record_answer( $db_id, $question, $step_id, $answer );

		// Side-effect: capture email/phone into Contact_Service.
		$this->maybe_capture_contact( $db_id, $question, $answer );

		// Partial submit: if partial_submit enabled and we just captured email/phone, send partial notification.
		$partial_submitted = false;
		if ( ! empty( $schema['partial_submit'] ) && $this->is_contact_question( $question ) && empty( $row['notified'] ) ) {
			if ( $this->mailer ) {
				$this->mailer->send_notification( $db_id, true );
				$partial_submitted = true;
			}
		}

		// Decide next.
		$branch_routed = false;
		$next_qid      = $this->resolve_next_question( $question, $answer, $schema, $branch_routed );
		if ( ! $branch_routed && null !== $next_qid && Niwa_AI_Lead_Form_Schema::COMPLETE_SENTINEL !== $next_qid ) {
			$next_qid = $this->skip_inactive_branch_targets( $next_qid, $schema, $db_id );
		}

		// Complete?
		if ( null === $next_qid || Niwa_AI_Lead_Form_Schema::COMPLETE_SENTINEL === $next_qid ) {
			$this->store->update_progress( $db_id, '', Niwa_AI_Lead_Form_Session_Store::STATUS_COMPLETED );
			if ( $this->mailer ) {
				$this->mailer->send_notification( $db_id, false );
			}
			return array(
				'question'          => null,
				'progress'          => $this->progress_for( $db_id, $schema, true, '' ),
				'completed'         => true,
				'partial_submitted' => $partial_submitted,
			);
		}

		// Advance.
		$next_entry = isset( $index[ $next_qid ] ) ? $index[ $next_qid ] : null;
		if ( ! $next_entry ) {
			// Next id not in schema — complete instead of looping.
			$this->store->update_progress( $db_id, '', Niwa_AI_Lead_Form_Session_Store::STATUS_COMPLETED );
			if ( $this->mailer ) {
				$this->mailer->send_notification( $db_id, false );
			}
			return array(
				'question'          => null,
				'progress'          => $this->progress_for( $db_id, $schema, true, '' ),
				'completed'         => true,
				'partial_submitted' => $partial_submitted,
			);
		}

		$this->store->update_progress( $db_id, $next_qid, Niwa_AI_Lead_Form_Session_Store::STATUS_ACTIVE );

		return array(
			'question'          => $this->render_question_for_client( $next_entry['question'] ),
			'progress'          => $this->progress_for( $db_id, $schema, false, $next_qid ),
			'completed'         => false,
			'partial_submitted' => $partial_submitted,
		);
	}

	/**
	 * Force-complete a session (admin tool / resume wrap-up).
	 *
	 * @param int $session_row_id DB id.
	 * @return void
	 */
	public function mark_completed( $session_row_id ) {
		$this->store->update_progress( (int) $session_row_id, '', Niwa_AI_Lead_Form_Session_Store::STATUS_COMPLETED );
	}

	/**
	 * Finalize a session — update status, optionally send notification.
	 *
	 * @param int  $session_row_id DB id.
	 * @param bool $is_partial     True for partial (abandoned but captured email/phone).
	 * @return void
	 */
	public function finalize( $session_row_id, $is_partial ) {
		$status = $is_partial
			? Niwa_AI_Lead_Form_Session_Store::STATUS_PARTIAL
			: Niwa_AI_Lead_Form_Session_Store::STATUS_COMPLETED;

		$this->store->update_progress( (int) $session_row_id, '', $status );

		if ( $this->mailer ) {
			$this->mailer->send_notification( (int) $session_row_id, (bool) $is_partial );
		}
	}

	/* ================================================================
	 * Internal helpers
	 * ============================================================= */

	/**
	 * Strip schema fields the client doesn't need (conditional rules, llm block).
	 *
	 * @param array $question Full question record.
	 * @return array
	 */
	private function render_question_for_client( array $question ) {
		return array(
			'id'          => isset( $question['id'] ) ? (string) $question['id'] : '',
			'label'       => isset( $question['label'] ) ? (string) $question['label'] : '',
			'type'        => isset( $question['type'] ) ? (string) $question['type'] : 'text',
			'required'    => ! empty( $question['required'] ) ? 1 : 0,
			'placeholder' => isset( $question['placeholder'] ) ? (string) $question['placeholder'] : '',
			'options'     => isset( $question['options'] ) && is_array( $question['options'] ) ? array_values( $question['options'] ) : array(),
			'appointment' => isset( $question['appointment'] ) && is_array( $question['appointment'] ) ? array(
				'service_ids'     => isset( $question['appointment']['service_ids'] ) && is_array( $question['appointment']['service_ids'] ) ? array_values( $question['appointment']['service_ids'] ) : array(),
				'team_member_ids' => isset( $question['appointment']['team_member_ids'] ) && is_array( $question['appointment']['team_member_ids'] ) ? array_values( $question['appointment']['team_member_ids'] ) : array(),
			) : array( 'service_ids' => array(), 'team_member_ids' => array() ),
			'validation'  => isset( $question['validation'] ) && is_array( $question['validation'] ) ? array(
				'min'   => array_key_exists( 'min', $question['validation'] ) ? $question['validation']['min'] : null,
				'max'   => array_key_exists( 'max', $question['validation'] ) ? $question['validation']['max'] : null,
				'regex' => isset( $question['validation']['regex'] ) ? (string) $question['validation']['regex'] : '',
			) : array( 'min' => null, 'max' => null, 'regex' => '' ),
		);
	}

	/**
	 * Completed-flow early-return payload.
	 *
	 * @param int   $db_id  DB id (0 if unknown).
	 * @param array $schema Schema.
	 * @return array
	 */
	private function completed_payload( $db_id, array $schema ) {
		$total = Niwa_AI_Lead_Form_Schema::total_questions( $schema );
		if ( (int) $db_id > 0 ) {
			return array(
				'session_row_id' => (int) $db_id,
				'mode'           => isset( $schema['mode'] ) ? (string) $schema['mode'] : Niwa_AI_Lead_Form_Schema::MODE_CHAT,
				'question'       => null,
				'progress'       => $this->progress_for( (int) $db_id, $schema, true, '' ),
				'completed'      => true,
			);
		}
		return array(
			'session_row_id' => (int) $db_id,
			'mode'           => isset( $schema['mode'] ) ? (string) $schema['mode'] : Niwa_AI_Lead_Form_Schema::MODE_CHAT,
			'question'       => null,
			'progress'       => array( 'current' => $total, 'total' => $total ),
			'completed'      => true,
		);
	}

	/**
	 * Compute progress by counting stored answers.
	 *
	 * @param int   $db_id          DB id.
	 * @param array $schema         Schema.
	 * @param bool  $force_complete Force current = total.
	 * @param string|null $current_qid Current unanswered question id, or empty when complete.
	 * @return array{current:int,total:int}
	 */
	private function progress_for( $db_id, array $schema, $force_complete = false, $current_qid = null ) {
		$answers = $this->store->get_answers( (int) $db_id );
		$current = is_array( $answers ) ? count( $answers ) : 0;
		$total   = $this->route_progress_total( $schema, is_array( $answers ) ? $answers : array(), $current_qid );

		if ( $force_complete ) {
			$total   = max( $current, $total );
			$current = $total;
		}
		if ( $current > $total ) {
			$current = $total;
		}
		return array( 'current' => (int) $current, 'total' => (int) $total );
	}

	/**
	 * Compute the progress denominator for the currently active route.
	 *
	 * @param array       $schema      Schema.
	 * @param array<int,array> $answers Stored answer rows.
	 * @param string|null $current_qid Current unanswered question id.
	 * @return int
	 */
	private function route_progress_total( array $schema, array $answers, $current_qid ) {
		$route = $this->answered_question_ids( $answers );
		if ( empty( $route ) && ( null === $current_qid || '' === (string) $current_qid ) ) {
			return Niwa_AI_Lead_Form_Schema::total_questions( $schema );
		}

		$index        = Niwa_AI_Lead_Form_Schema::question_index( $schema );
		$branch_route = $route;
		if ( null !== $current_qid && '' !== (string) $current_qid ) {
			$branch_route[] = (string) $current_qid;
		}
		$excluded     = $this->inactive_branch_targets_for_route( $schema, $branch_route );
		$answered_set = array();
		foreach ( $route as $qid ) {
			$answered_set[ $qid ] = true;
		}

		$total = count( $route );
		$next  = null === $current_qid ? '' : (string) $current_qid;
		$guard = 0;
		$limit = max( 1, Niwa_AI_Lead_Form_Schema::total_questions( $schema ) );

		while ( '' !== $next && null !== $next && isset( $index[ $next ] ) && $guard <= $limit ) {
			if ( ! isset( $answered_set[ $next ] ) && ! isset( $excluded[ $next ] ) ) {
				$total++;
			}
			$next = Niwa_AI_Lead_Form_Schema::next_in_order( $schema, $next );
			$guard++;
		}

		return max( 1, (int) $total );
	}

	/**
	 * Extract answered question ids in submission order.
	 *
	 * @param array<int,array> $answers Answer rows.
	 * @return array<int,string>
	 */
	private function answered_question_ids( array $answers ) {
		$route = array();
		foreach ( $answers as $row ) {
			if ( empty( $row['question_id'] ) ) {
				continue;
			}
			$route[] = (string) $row['question_id'];
		}
		return $route;
	}

	/**
	 * Validate an answer against question spec.
	 *
	 * @param array $question Question.
	 * @param mixed $answer   Answer.
	 * @return string|null    Error message, or null if valid.
	 */
	private function validate_answer( array $question, $answer ) {
		$type     = isset( $question['type'] ) ? (string) $question['type'] : 'text';
		$required = ! empty( $question['required'] );

		$is_empty = $this->answer_is_empty( $answer );

		if ( $required && $is_empty ) {
			return __( 'Ovo polje je obavezno.', 'niwa-ai' );
		}
		if ( $is_empty ) {
			return null;
		}

		switch ( $type ) {
			case 'email':
				$email = is_array( $answer ) ? '' : trim( (string) $answer );
				if ( '' === $email || ! is_email( $email ) ) {
					return __( 'Unesite ispravnu email adresu.', 'niwa-ai' );
				}
				break;

			case 'phone':
				$phone = is_array( $answer ) ? '' : trim( (string) $answer );
				// Loose intl phone: digits, spaces, dashes, parens, leading +.
				if ( ! preg_match( '/^\+?[0-9()\-\s]{6,20}$/', $phone ) ) {
					return __( 'Unesite ispravan broj telefona.', 'niwa-ai' );
				}
				break;

			case 'number':
				$num = is_array( $answer ) ? null : trim( (string) $answer );
				if ( null === $num || '' === $num || ! is_numeric( $num ) ) {
					return __( 'Unesite broj.', 'niwa-ai' );
				}
				break;

			case 'choice_single':
				$allowed = $this->allowed_option_values( $question );
				$val     = is_array( $answer ) ? ( isset( $answer[0] ) ? (string) $answer[0] : '' ) : (string) $answer;
				if ( ! empty( $allowed ) && ! in_array( $val, $allowed, true ) ) {
					return __( 'Izabrana vrednost nije dozvoljena.', 'niwa-ai' );
				}
				break;

			case 'choice_multi':
				$allowed = $this->allowed_option_values( $question );
				$vals    = is_array( $answer ) ? array_map( 'strval', $answer ) : array( (string) $answer );
				foreach ( $vals as $v ) {
					if ( ! empty( $allowed ) && ! in_array( $v, $allowed, true ) ) {
						return __( 'Neka od izabranih vrednosti nije dozvoljena.', 'niwa-ai' );
					}
				}
				break;

			case 'text':
			case 'textarea':
			default:
				// No type-specific check beyond validation block below.
				break;
		}

		// Validation block: regex / min / max.
		$validation = isset( $question['validation'] ) && is_array( $question['validation'] ) ? $question['validation'] : array();

		$regex = isset( $validation['regex'] ) ? (string) $validation['regex'] : '';
		if ( '' !== $regex && ! is_array( $answer ) ) {
			$pattern = $this->normalize_regex( $regex );
			if ( null !== $pattern ) {
				// Suppress warnings from malformed user regex; treat failure as validation error.
				$matched = @preg_match( $pattern, (string) $answer );
				if ( 1 !== $matched ) {
					return __( 'Odgovor ne odgovara traženom formatu.', 'niwa-ai' );
				}
			}
		}

		$min = array_key_exists( 'min', $validation ) ? $validation['min'] : null;
		$max = array_key_exists( 'max', $validation ) ? $validation['max'] : null;

		if ( null !== $min || null !== $max ) {
			if ( 'number' === $type ) {
				$num = (float) $answer;
				if ( null !== $min && $num < (float) $min ) {
					return sprintf( __( 'Minimalna vrednost je %s.', 'niwa-ai' ), (string) $min );
				}
				if ( null !== $max && $num > (float) $max ) {
					return sprintf( __( 'Maksimalna vrednost je %s.', 'niwa-ai' ), (string) $max );
				}
			} else {
				$len = is_array( $answer ) ? count( $answer ) : mb_strlen( (string) $answer );
				if ( null !== $min && $len < (int) $min ) {
					return sprintf( __( 'Minimalna dužina je %d.', 'niwa-ai' ), (int) $min );
				}
				if ( null !== $max && $len > (int) $max ) {
					return sprintf( __( 'Maksimalna dužina je %d.', 'niwa-ai' ), (int) $max );
				}
			}
		}

		return null;
	}

	/**
	 * Check if an answer is "empty" for validation purposes.
	 *
	 * @param mixed $answer Answer.
	 * @return bool
	 */
	private function answer_is_empty( $answer ) {
		if ( is_array( $answer ) ) {
			foreach ( $answer as $v ) {
				if ( '' !== trim( (string) $v ) ) {
					return false;
				}
			}
			return true;
		}
		return '' === trim( (string) $answer );
	}

	/**
	 * Extract allowed option values from a choice question.
	 *
	 * @param array $question Question.
	 * @return array
	 */
	private function allowed_option_values( array $question ) {
		if ( empty( $question['options'] ) || ! is_array( $question['options'] ) ) {
			return array();
		}
		$out = array();
		foreach ( $question['options'] as $opt ) {
			if ( is_array( $opt ) && isset( $opt['value'] ) ) {
				$out[] = (string) $opt['value'];
			}
		}
		return $out;
	}

	/**
	 * Ensure a regex has delimiters. Admin form may or may not supply them.
	 *
	 * @param string $regex Raw regex.
	 * @return string|null Canonical pattern or null if irredeemable.
	 */
	private function normalize_regex( $regex ) {
		$regex = (string) $regex;
		if ( '' === $regex ) {
			return null;
		}
		// Already delimited?
		if ( preg_match( '/^(\/.*\/[a-zA-Z]*|#.*#[a-zA-Z]*|~.*~[a-zA-Z]*)$/s', $regex ) ) {
			return $regex;
		}
		// Wrap in # — safer than / if user included slashes.
		$escaped = str_replace( '#', '\#', $regex );
		return '#' . $escaped . '#';
	}

	/**
	 * Resolve next question id via: deterministic rules → LLM → next_in_order.
	 *
	 * @param array $question      Answered question.
	 * @param mixed $answer        Answer.
	 * @param array $schema        Schema.
	 * @param bool  $branch_routed Set true when a rule/LLM branch chose the next id.
	 * @return string|null    Next qid, COMPLETE_SENTINEL, or null (→ flow done).
	 */
	private function resolve_next_question( array $question, $answer, array $schema, &$branch_routed = false ) {
		$branch_routed = false;
		$conditional = isset( $question['conditional'] ) && is_array( $question['conditional'] ) ? $question['conditional'] : array();

		// Deterministic rules (only when enabled).
		if ( ! empty( $conditional['enabled'] ) && ! empty( $conditional['rules'] ) && is_array( $conditional['rules'] ) ) {
			foreach ( $conditional['rules'] as $rule ) {
				if ( ! is_array( $rule ) ) {
					continue;
				}
				if ( $this->rule_matches( $rule, $answer ) ) {
					$next = isset( $rule['next_question'] ) ? (string) $rule['next_question'] : '';
					$branch_routed = true;
					return '' === $next ? null : $next;
				}
			}
		}

		// LLM router.
		if ( ! empty( $conditional['llm_enabled'] ) && $this->llm_router && ! empty( $conditional['llm_candidates'] ) && is_array( $conditional['llm_candidates'] ) ) {
			$instr  = isset( $conditional['llm_instruction'] ) ? (string) $conditional['llm_instruction'] : '';
			$chosen = $this->llm_router->choose_next(
				$question,
				$answer,
				$conditional['llm_candidates'],
				$instr,
				$schema
			);
			if ( null !== $chosen && '' !== $chosen ) {
				$branch_routed = true;
				return $chosen;
			}
		}

		// Fallback: document order.
		return Niwa_AI_Lead_Form_Schema::next_in_order( $schema, isset( $question['id'] ) ? (string) $question['id'] : '' );
	}

	/**
	 * Continue document order while omitting branch entry points that belong to
	 * an already answered alternative path.
	 *
	 * @param string $next_qid Candidate next question id.
	 * @param array  $schema   Schema.
	 * @param int    $db_id    Session DB id.
	 * @return string|null
	 */
	private function skip_inactive_branch_targets( $next_qid, array $schema, $db_id ) {
		$excluded = $this->inactive_branch_targets( $schema, (int) $db_id );
		if ( empty( $excluded ) ) {
			return $next_qid;
		}

		$total = max( 1, Niwa_AI_Lead_Form_Schema::total_questions( $schema ) );
		$guard = 0;
		while ( null !== $next_qid && isset( $excluded[ $next_qid ] ) && $guard <= $total ) {
			$next_qid = Niwa_AI_Lead_Form_Schema::next_in_order( $schema, $next_qid );
			$guard++;
		}

		return $next_qid;
	}

	/**
	 * Build a set of branch-target question ids that are not on the active route.
	 *
	 * This is based only on schema ids and saved answer order. If an answered
	 * branching question has multiple possible target ids and exactly one target
	 * appears later in the submitted route, the other target ids are inactive.
	 *
	 * @param array $schema Schema.
	 * @param int   $db_id  Session DB id.
	 * @return array<string,bool>
	 */
	private function inactive_branch_targets( array $schema, $db_id ) {
		$answers = $this->store->get_answers( (int) $db_id );
		if ( empty( $answers ) || ! is_array( $answers ) ) {
			return array();
		}

		return $this->inactive_branch_targets_for_route( $schema, $this->answered_question_ids( $answers ) );
	}

	/**
	 * Build inactive branch target ids from an already submitted route.
	 *
	 * @param array             $schema Schema.
	 * @param array<int,string> $route  Answered question ids in order.
	 * @return array<string,bool>
	 */
	private function inactive_branch_targets_for_route( array $schema, array $route ) {
		$index    = Niwa_AI_Lead_Form_Schema::question_index( $schema );
		$excluded = array();

		$route_count = count( $route );
		for ( $i = 0; $i < $route_count; $i++ ) {
			$qid = $route[ $i ];
			if ( ! isset( $index[ $qid ]['question'] ) ) {
				continue;
			}

			$targets = $this->branch_target_ids( $index[ $qid ]['question'], $schema );
			if ( count( $targets ) < 2 ) {
				continue;
			}

			$selected = '';
			for ( $j = $i + 1; $j < $route_count; $j++ ) {
				if ( isset( $targets[ $route[ $j ] ] ) ) {
					$selected = $route[ $j ];
					break;
				}
			}

			if ( '' === $selected ) {
				continue;
			}

			foreach ( $targets as $target_id => $_ ) {
				if ( $target_id !== $selected ) {
					$excluded[ $target_id ] = true;
				}
			}
		}

		return $excluded;
	}

	/**
	 * Return direct branch target ids declared by rules and LLM candidates.
	 *
	 * @param array $question Question.
	 * @param array $schema   Schema.
	 * @return array<string,bool>
	 */
	private function branch_target_ids( array $question, array $schema ) {
		$conditional = isset( $question['conditional'] ) && is_array( $question['conditional'] ) ? $question['conditional'] : array();
		if ( empty( $conditional ) ) {
			return array();
		}

		$index   = Niwa_AI_Lead_Form_Schema::question_index( $schema );
		$targets = array();

		if ( ! empty( $conditional['enabled'] ) && ! empty( $conditional['rules'] ) && is_array( $conditional['rules'] ) ) {
			foreach ( $conditional['rules'] as $rule ) {
				if ( ! is_array( $rule ) || empty( $rule['next_question'] ) ) {
					continue;
				}
				$next = (string) $rule['next_question'];
				if ( Niwa_AI_Lead_Form_Schema::COMPLETE_SENTINEL !== $next && isset( $index[ $next ] ) ) {
					$targets[ $next ] = true;
				}
			}
		}

		if ( ! empty( $conditional['llm_enabled'] ) && ! empty( $conditional['llm_candidates'] ) && is_array( $conditional['llm_candidates'] ) ) {
			foreach ( $conditional['llm_candidates'] as $candidate ) {
				$candidate = (string) $candidate;
				if ( isset( $index[ $candidate ] ) ) {
					$targets[ $candidate ] = true;
				}
			}
		}

		return $targets;
	}

	/**
	 * Evaluate a conditional rule against an answer.
	 *
	 * @param array $rule   Rule.
	 * @param mixed $answer Answer.
	 * @return bool
	 */
	private function rule_matches( array $rule, $answer ) {
		$match_type  = isset( $rule['match_type'] ) ? (string) $rule['match_type'] : 'equals';
		$match_value = isset( $rule['match_value'] ) ? $rule['match_value'] : '';

		$answer_str  = is_array( $answer ) ? implode( ',', array_map( 'strval', $answer ) ) : (string) $answer;
		$answer_list = is_array( $answer ) ? array_map( 'strval', $answer ) : array( (string) $answer );

		switch ( $match_type ) {
			case 'equals':
				$needle = is_array( $match_value ) ? ( isset( $match_value[0] ) ? (string) $match_value[0] : '' ) : (string) $match_value;
				return 0 === strcasecmp( trim( $answer_str ), trim( $needle ) );

			case 'contains':
				$needle = is_array( $match_value ) ? ( isset( $match_value[0] ) ? (string) $match_value[0] : '' ) : (string) $match_value;
				if ( '' === $needle ) {
					return false;
				}
				return false !== stripos( $answer_str, $needle );

			case 'regex':
				$pattern = $this->normalize_regex( is_array( $match_value ) ? '' : (string) $match_value );
				if ( null === $pattern ) {
					return false;
				}
				return 1 === @preg_match( $pattern, $answer_str );

			case 'any_of':
				$candidates = is_array( $match_value ) ? array_map( 'strval', $match_value ) : array( (string) $match_value );
				foreach ( $candidates as $c ) {
					$c_trim = trim( $c );
					if ( '' === $c_trim ) {
						continue;
					}
					foreach ( $answer_list as $a ) {
						if ( 0 === strcasecmp( trim( $a ), $c_trim ) ) {
							return true;
						}
					}
				}
				return false;
		}

		return false;
	}

	/**
	 * Detect if a question captures an email/phone contact field.
	 *
	 * @param array $question Question.
	 * @return bool
	 */
	private function is_contact_question( array $question ) {
		$type = isset( $question['type'] ) ? (string) $question['type'] : '';
		return 'email' === $type || 'phone' === $type;
	}

	/**
	 * Save captured email/phone into Contact_Service and attach to session.
	 *
	 * @param int   $db_id    Session DB id.
	 * @param array $question Question.
	 * @param mixed $answer   Answer.
	 * @return void
	 */
	private function maybe_capture_contact( $db_id, array $question, $answer ) {
		if ( ! $this->is_contact_question( $question ) ) {
			return;
		}
		$type  = isset( $question['type'] ) ? (string) $question['type'] : '';
		$value = is_array( $answer ) ? ( isset( $answer[0] ) ? (string) $answer[0] : '' ) : (string) $answer;
		$value = trim( $value );
		if ( '' === $value ) {
			return;
		}

		$email = '';
		$phone = '';
		if ( 'email' === $type ) {
			$email = $value;
		} else {
			$phone = $value;
		}

		$result = $this->contact_service->save_contact( $email, '', $phone, 'lead_form' );
		if ( is_array( $result ) && ! empty( $result['success'] ) && ! empty( $result['id'] ) ) {
			$this->store->attach_contact( (int) $db_id, (int) $result['id'] );
		}
	}
}
