<?php
/**
 * Shared conversation persistence and usage helpers for public widgets.
 *
 * @package SiteStaffr
 */

class SiteStaffr_Public_Conversation_Usage {

	/**
	 * AJAX handler for saving conversation transcript.
	 *
	 * Saves full WebRTC conversation to database (call record + transcript turns).
	 * Called by browser when conversation ends.
	 *
	 * @since 0.13.49
	 * @param string $ip_address Client IP address resolved by the caller.
	 * @return void
	 */
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
	public static function ajax_save_conversation( $ip_address ) {
		// Security guards: nonce, per-IP rate limit.
		SiteStaffr_Public_Request_Guards::enforce_public_guards( array(
			'nonce_action' => 'sitestaffr_save_conversation',
			'ip'           => $ip_address,
			'rate_limit'   => 20,
		) );

		// Get and validate inputs
		$session_id = SiteStaffr_Public_Request_Guards::get_post_text_field( 'session_id' );
		$duration = SiteStaffr_Public_Request_Guards::get_post_absint( 'duration' );
		$transcript_json = SiteStaffr_Public_Request_Guards::get_post_raw_field( 'transcript' );
		$realtime_usage_json = SiteStaffr_Public_Request_Guards::get_post_raw_field( 'realtime_usage' );
		$channel_source = SiteStaffr_Public_Request_Guards::get_post_key_field( 'channel_source' );
		if ( '' === $channel_source ) {
			$channel_source = 'widget';
		}

		$conversation_mode = SiteStaffr_Public_Request_Guards::get_post_key_field( 'conversation_mode' );
		if ( ! in_array( $conversation_mode, array( 'voice', 'text' ), true ) ) {
			$conversation_mode = 'voice';
		}

		$persona = SiteStaffr_Public_Request_Guards::get_post_key_field( 'persona' );
		if ( '' === $persona ) {
			$persona = 'default';
		}

		$ended_by = self::parse_ended_by_field();

		if ( empty( $session_id ) || empty( $transcript_json ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Save Conversation: Missing required fields' );
			wp_send_json_error( array(
				'message' => SiteStaffr_Public_Middleware_Client::msg_missing_required_fields(),
			), 400 );
			return;
		}

		// Parse transcript JSON
		$transcript = json_decode( $transcript_json, true );
		if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $transcript ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Save Conversation: Invalid transcript JSON' );
			wp_send_json_error( array(
				'message' => SiteStaffr_Public_Middleware_Client::msg_invalid_transcript_format(),
			), 400 );
			return;
		}

		// Validate transcript structure
		if ( empty( $transcript ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Save Conversation: Empty transcript' );
			wp_send_json_error( array(
				'message' => SiteStaffr_Public_Middleware_Client::msg_transcript_cannot_be_empty(),
			), 400 );
			return;
		}

		$realtime_usage = self::parse_realtime_usage_payload( $realtime_usage_json );

		$contact_fields = self::parse_contact_fields_payload( SiteStaffr_Public_Request_Guards::get_post_raw_field( 'contact_fields' ) );

		$submission_source = SiteStaffr_Public_Request_Guards::get_post_key_field( 'submission_source' );
		if ( ! in_array( $submission_source, array( 'contact_form' ), true ) ) {
			$submission_source = '';
		}

		// Conversation source page (which page the widget was opened on). This arrives
		// on an unauthenticated endpoint and is rendered as a link in wp-admin, so it is
		// constrained to this site — the field means "a page of YOUR site", and without
		// the check any visitor could plant an arbitrary outbound link in the admin.
		$source_page_url = esc_url_raw( SiteStaffr_Public_Request_Guards::get_post_text_field( 'page_url' ) );
		if ( '' !== $source_page_url ) {
			$source_host = wp_parse_url( $source_page_url, PHP_URL_HOST );
			$site_host   = wp_parse_url( home_url(), PHP_URL_HOST );
			if ( ! $source_host || ! $site_host || strtolower( $source_host ) !== strtolower( $site_host ) ) {
				$source_page_url = '';
			}
		}
		$source_page_title = mb_substr( SiteStaffr_Public_Request_Guards::get_post_text_field( 'page_title' ), 0, 255 );

		global $wpdb;
		$calls_table = $wpdb->prefix . 'phoneease_calls';
		$transcripts_table = $wpdb->prefix . 'phoneease_transcripts';

		// Resume-safe idempotency: the widget lets a visitor keep chatting after a
		// save (tab hidden → beacon save → visitor returns and types again). A
		// re-save of the same session must UPDATE the existing record — never
		// insert a duplicate row, never drop the continuation turns, and never
		// re-send the notification email (a resumed chat used to produce two
		// dashboard records and two lead emails).
		$existing_id = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT id FROM ' . $calls_table . ' WHERE session_id = %s LIMIT 1',
				$session_id
			)
		);
		if ( $existing_id ) {
			$call_id = (int) $existing_id;

			$update_fields  = array(
				'call_duration' => $duration,
				'is_read'       => 0,
			);
			$update_formats = array( '%d', '%d' );
			if ( null !== $ended_by ) {
				$update_fields['ended_by'] = $ended_by;
				$update_formats[]          = '%s';
			}
			// MW-created voice rows have no page context at insert; fill it on save.
			if ( '' !== $source_page_url ) {
				$update_fields['source_page_url'] = $source_page_url;
				$update_formats[]                 = '%s';
			}
			if ( '' !== $source_page_title ) {
				$update_fields['source_page_title'] = $source_page_title;
				$update_formats[]                   = '%s';
			}
			$wpdb->update( $calls_table, $update_fields, array( 'id' => $call_id ), $update_formats, array( '%d' ) );

			// Replace the stored turns with the full (longer) transcript.
			$wpdb->delete( $transcripts_table, array( 'call_id' => $call_id ), array( '%d' ) );
			$transcript_errors = self::insert_transcript_turns( $call_id, $transcript );

			// Refresh the recap and lead flag for the extended conversation.
			// Deliberately NOT firing sitestaffr_call_completed here: the email
			// notification already went out on the first save.
			if ( class_exists( 'SiteStaffr_AI_Summary_Generator' ) ) {
				( new SiteStaffr_AI_Summary_Generator() )->generate_and_store_summary( $call_id, 'completed', $contact_fields, $submission_source );
			}
			if ( class_exists( 'SiteStaffr_Leads' ) ) {
				SiteStaffr_Leads::maybe_flag_qualified_lead( $call_id, 'completed', $contact_fields );
			}

			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr Save Conversation: Updated resumed call_id=%d, session_id=%s, duration=%ds, turns=%d, errors=%d',
				$call_id,
				$session_id,
				$duration,
				count( $transcript ),
				$transcript_errors
			) );

			wp_send_json_success( array(
				'call_id' => $call_id,
				'message' => 'Conversation updated',
			) );
			return;
		}

		// Calculate started_at timestamp (current time - duration)
		$started_at = time() - $duration;
		if ( 'text' === $conversation_mode ) {
			$service_type = ( 'button' === $channel_source ) ? 'chat_button' : 'chat_widget';
		} else {
			$service_type = ( 'button' === $channel_source ) ? 'webrtc_button' : 'webrtc_widget';
		}
		$visitor_ip = ( '0.0.0.0' !== $ip_address && filter_var( $ip_address, FILTER_VALIDATE_IP ) ) ? $ip_address : null;

		// Insert call record
		$call_result = $wpdb->insert(
			$calls_table,
			array(
				'session_id'   => $session_id,
				'service_type' => $service_type,
				'persona'      => $persona,
				'ended_by'     => $ended_by,
				'from_number'  => null,
				'to_number'    => null,
				'visitor_ip'   => $visitor_ip,
				'call_status'  => 'completed',
				'call_duration' => $duration,
				'started_at'   => $started_at,
				'is_read'      => 0,
				'is_test_call' => SiteStaffr_Test_Mode::claim_pending( $session_id ) ? 1 : 0,
				'source_page_url'   => '' !== $source_page_url ? $source_page_url : null,
				'source_page_title' => '' !== $source_page_title ? $source_page_title : null,
				'created_at'   => current_time( 'mysql' ),
			),
			array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' )
		);

		if ( $call_result === false ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Save Conversation: Failed to insert call record - ' . $wpdb->last_error );
			wp_send_json_error( array(
				'message' => 'Database error: could not save call record',
			), SiteStaffr_Public_Middleware_Client::http_500() );
			return;
		}

		$call_id = $wpdb->insert_id;

		// Insert transcript turns
		$transcript_errors = self::insert_transcript_turns( $call_id, $transcript );

		$realtime_usage_logged = false;
		if ( ! empty( $realtime_usage ) ) {
			$realtime_usage_logged = self::log_realtime_usage_metadata( $call_id, $realtime_usage );
		}

		// Log success
		SiteStaffr_Logger::legacy( sprintf(
			'SiteStaffr Save Conversation: Saved call_id=%d, session_id=%s, duration=%ds, turns=%d, errors=%d, realtime_usage_logged=%s',
			$call_id,
			$session_id,
			$duration,
			count( $transcript ),
			$transcript_errors,
			$realtime_usage_logged ? 'yes' : 'no'
		) );

		// Trigger post-call processing (AI summary generation + email notifications).
		// Guard against accidental echoed output from hooked callbacks corrupting
		// the JSON AJAX response body.
		ob_start();
		do_action( 'sitestaffr_call_completed', $call_id, 'completed', $contact_fields, $submission_source );
		$hook_output = ob_get_clean();
		if ( is_string( $hook_output ) && '' !== trim( $hook_output ) ) {
			SiteStaffr_Logger::legacy(
				sprintf(
					'SiteStaffr Save Conversation: Suppressed %d bytes of unexpected hook output for call_id=%d',
					strlen( $hook_output ),
					$call_id
				)
			);
		}

		wp_send_json_success( array(
			'call_id' => $call_id,
			'message' => 'Conversation saved successfully',
		) );
	}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

	/**
	 * Insert transcript turns for a call.
	 *
	 * @since 1.33.1
	 * @param int   $call_id    Saved call ID.
	 * @param array $transcript Parsed transcript turns from the save payload.
	 * @return int Number of turns that failed validation or insertion.
	 */
	// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
	private static function insert_transcript_turns( $call_id, $transcript ) {
		global $wpdb;
		$transcripts_table = $wpdb->prefix . 'phoneease_transcripts';

		$transcript_errors = 0;
		foreach ( $transcript as $turn ) {
			// Validate turn structure
			if ( ! isset( $turn['role'] ) || ! isset( $turn['text'] ) ) {
				$transcript_errors++;
				continue;
			}

			// Map role: 'user' -> 'caller', 'assistant' -> 'ai'
			$speaker = ( $turn['role'] === 'user' ) ? 'caller' : 'ai';
			$message_text = sanitize_textarea_field( $turn['text'] );
			// Chunk attribution is only meaningful for AI turns; best-effort (never blocks the save).
			$source_chunks = ( 'ai' === $speaker && isset( $turn['source_chunks'] ) )
				? sitestaffr_sanitize_source_chunks( $turn['source_chunks'] )
				: null;
			$timestamp = isset( $turn['timestamp'] ) ? sanitize_text_field( $turn['timestamp'] ) : current_time( 'mysql' );

			// Convert ISO timestamp to MySQL datetime if needed
			$turn_ts = strtotime( $timestamp );
			if ( $turn_ts !== false ) {
				$timestamp = gmdate( 'Y-m-d H:i:s', $turn_ts );
			} else {
				$timestamp = current_time( 'mysql' );
			}

			$transcript_result = $wpdb->insert(
				$transcripts_table,
				array(
					'call_id'       => $call_id,
					'speaker'       => $speaker,
					'message_text'  => $message_text,
					'source_chunks' => $source_chunks,
					'timestamp'     => $timestamp,
					'created_at'    => current_time( 'mysql' ),
				),
				array( '%d', '%s', '%s', '%s', '%s', '%s' )
			);

			if ( $transcript_result === false ) {
				SiteStaffr_Logger::legacy( sprintf(
					'SiteStaffr Save Conversation: Failed to insert transcript turn - %s',
					$wpdb->last_error
				) );
				$transcript_errors++;
			}
		}

		return $transcript_errors;
	}
	// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

	/**
	 * Read and allowlist the ended_by field from the save payload.
	 *
	 * Ambiguous ends (connection lost, session errors, cap/entitlement contact
	 * forms, unknown) are deliberately null: never guess.
	 *
	 * @since 1.25.0
	 * @return string|null One of ai_ended|visitor_left|timed_out, or null.
	 */
	public static function parse_ended_by_field() {
		$ended_by = SiteStaffr_Public_Request_Guards::get_post_key_field( 'ended_by' );
		return in_array( $ended_by, array( 'ai_ended', 'visitor_left', 'timed_out', 'link_clicked' ), true ) ? $ended_by : null;
	}

	/**
	 * Parse and sanitize Realtime usage payload from browser.
	 *
	 * @since 0.13.81
	 * @param string $usage_json Raw JSON payload from AJAX body.
	 * @return array Sanitized usage payload (empty array if unavailable/invalid).
	 */
	public static function parse_realtime_usage_payload( $usage_json ) {
		if ( empty( $usage_json ) ) {
			return array();
		}

		$decoded = json_decode( $usage_json, true );
		if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $decoded ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Save Conversation: Invalid realtime_usage payload' );
			return array();
		}

		return array(
			'input_tokens'        => isset( $decoded['input_tokens'] ) ? max( 0, intval( $decoded['input_tokens'] ) ) : 0,
			'output_tokens'       => isset( $decoded['output_tokens'] ) ? max( 0, intval( $decoded['output_tokens'] ) ) : 0,
			'cached_input_tokens' => isset( $decoded['cached_input_tokens'] ) ? max( 0, intval( $decoded['cached_input_tokens'] ) ) : 0,
			'response_count'      => isset( $decoded['response_count'] ) ? max( 0, intval( $decoded['response_count'] ) ) : 0,
			'model'               => isset( $decoded['model'] ) ? sanitize_text_field( $decoded['model'] ) : 'realtime-session',
		);
	}

	/**
	 * Store Realtime conversation usage metrics in AI metadata table.
	 *
	 * @since 0.13.81
	 * @param int   $call_id        Saved call ID.
	 * @param array $realtime_usage Sanitized usage payload.
	 * @return bool True on success, false on failure.
	 */
	// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
	public static function log_realtime_usage_metadata( $call_id, $realtime_usage ) {
		global $wpdb;

		if ( empty( $call_id ) || empty( $realtime_usage ) ) {
			return false;
		}

		$table_name = $wpdb->prefix . 'phoneease_ai_metadata';
		$prompt_tokens = isset( $realtime_usage['input_tokens'] ) ? intval( $realtime_usage['input_tokens'] ) : 0;
		$completion_tokens = isset( $realtime_usage['output_tokens'] ) ? intval( $realtime_usage['output_tokens'] ) : 0;
		$cached_tokens = isset( $realtime_usage['cached_input_tokens'] ) ? intval( $realtime_usage['cached_input_tokens'] ) : 0;
		$response_count = isset( $realtime_usage['response_count'] ) ? intval( $realtime_usage['response_count'] ) : 0;

		// Skip zero-token conversation metadata rows when usage was not returned by Realtime events.
		if ( $response_count <= 0 && $prompt_tokens <= 0 && $completion_tokens <= 0 && $cached_tokens <= 0 ) {
			return false;
		}

		$inserted = $wpdb->insert(
			$table_name,
			array(
				'call_id'           => $call_id,
				'prompt_tokens'     => $prompt_tokens,
				'completion_tokens' => $completion_tokens,
				'cached_tokens'     => $cached_tokens,
				'total_tokens'      => $prompt_tokens + $completion_tokens + $cached_tokens,
				'model_used'        => isset( $realtime_usage['model'] ) ? $realtime_usage['model'] : 'realtime-session',
				'operation_type'    => 'conversation',
				'created_at'        => current_time( 'mysql' ),
			),
			array( '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%s' )
		);

		if ( false === $inserted ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Save Conversation: Failed to log realtime usage metadata - ' . $wpdb->last_error );
			return false;
		}

		return true;
	}
	// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

	/**
	 * Parse and sanitize contact fields payload from browser.
	 *
	 * Expects JSON with keys: name, phone, email, reason (default persona)
	 * plus business_name, website_url (onboarding persona).
	 *
	 * @since 1.3.3
	 * @param string $json Raw JSON payload from AJAX body.
	 * @return array Sanitized contact fields (empty strings for missing keys).
	 */
	public static function parse_contact_fields_payload( $json ) {
		$defaults = array(
			'name'          => '',
			'phone'         => '',
			'email'         => '',
			'reason'        => '',
			'business_name' => '',
			'website_url'   => '',
		);

		if ( empty( $json ) ) {
			return $defaults;
		}

		$decoded = json_decode( $json, true );
		if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $decoded ) ) {
			return $defaults;
		}

		return array(
			'name'          => isset( $decoded['name'] ) ? sanitize_text_field( $decoded['name'] ) : '',
			'phone'         => isset( $decoded['phone'] ) ? sanitize_text_field( $decoded['phone'] ) : '',
			'email'         => isset( $decoded['email'] ) ? sanitize_text_field( $decoded['email'] ) : '',
			'reason'        => isset( $decoded['reason'] ) ? sanitize_text_field( $decoded['reason'] ) : '',
			'business_name' => isset( $decoded['business_name'] ) ? sanitize_text_field( $decoded['business_name'] ) : '',
			'website_url'   => isset( $decoded['website_url'] ) ? esc_url_raw( preg_match( '/^https?:\/\//i', $decoded['website_url'] ) ? $decoded['website_url'] : 'https://' . $decoded['website_url'] ) : '',
		);
	}
}
