<?php
/**
 * AI Summary Generator.
 *
 * Automatically generates and stores AI summaries when calls complete.
 *
 * @link       https://github.com/sitestaffr/sitestaffr-plugin
 * @since      1.0.0
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 */

/**
 * AI Summary Generator class.
 *
 * Hooks into call completion to automatically generate AI summaries
 * and store them in the database for reuse across features.
 *
 * @since      1.0.0
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 * @author     SiteStaffr
 */
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin table names are internally derived from $wpdb->prefix and values are placeholder-prepared.
class SiteStaffr_AI_Summary_Generator {
    /**
     * Fallback label when middleware does not return a model name.
     *
     * @var string
     */
    const MODEL_FALLBACK = 'middleware-default';

    /**
     * DEPRECATED: API Secret constant removed for security (v0.10.101)
     *
     * SECURITY: API secret is now stored in WordPress options table (sitestaffr_api_secret)
     * instead of being hardcoded in source code. This prevents exposure in Git history.
     *
     * Use get_api_secret() method to retrieve the secret from WordPress options.
     *
     * @since 0.9.49
     * @deprecated 0.10.101
     */

    /**
     * Initialize the class and set up hooks.
     *
     * @since    1.0.0
     */
    public function __construct() {
        // NOTE: Hook registration moved to class-sitestaffr.php define_ai_summary_hooks()
        // to prevent duplicate registration. The hook is registered via the loader.

    }

    /**
     * Generate and store AI summary when a call completes.
     *
     * This method is triggered automatically when a call reaches a terminal status.
     * It generates an AI summary and stores it in the database for later use.
     *
     * @since    1.0.0
     * @param    int       $call_id            The call ID from the database.
     * @param    string    $call_status        The final call status.
     * @param    array     $contact_fields     Contact fields captured via function calling.
     * @param    string    $submission_source  Origin of the submission (e.g. 'contact_form' when the free fallback form is used).
     * @param    string    $test_model         Optional AI model for multi-model testing.
     * @return   bool|WP_Error                 True on success, WP_Error on failure.
     */
    public function generate_and_store_summary( $call_id, $call_status = '', $contact_fields = array(), $submission_source = '', $test_model = null ) {
        if ( ! empty( $test_model ) ) {
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary: Using test model for call $call_id: $test_model" );
        }

        // Get call data and transcripts
        global $wpdb;
        $calls_table = $wpdb->prefix . 'phoneease_calls';
        $call_data = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT call_duration, started_at, persona FROM $calls_table WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted plugin table name.
                $call_id
            )
        );

        if ( ! $call_data ) {
            $error_msg = "Call record not found for call_id $call_id";
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg" );
            return new WP_Error(
                'call_not_found',
                $error_msg,
                array( 'call_id' => $call_id, 'status' => $call_status )
            );
        }

        // For onboarding conversations, build recap directly from contact fields (skip AI API call).
        $persona = isset( $call_data->persona ) ? sanitize_key( $call_data->persona ) : 'default';
        if ( 'onboarding' === $persona && ! empty( $contact_fields ) && is_array( $contact_fields ) && array_filter( $contact_fields, 'strlen' ) ) {
            $recap_html = $this->build_onboarding_recap_html( $contact_fields );
            $result = SiteStaffr_Database::update_call_ai_summary( $call_id, $recap_html, '' );
            if ( false !== $result ) {
                SiteStaffr_Logger::legacy( "SiteStaffr AI Summary: Stored onboarding recap for call $call_id" );
                return true;
            }
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: Failed to store onboarding recap for call $call_id" );
            return new WP_Error( 'db_update_failed', "Failed to store onboarding recap for call $call_id" );
        }

        // For free contact form fallback submissions, guarantee captured fields appear
        // in the recap and optionally append an AI summary + follow-up for the message.
        if ( 'contact_form' === $submission_source ) {
            $recap_html = $this->build_contact_form_recap_html( $call_id, $contact_fields, $test_model );
            $result = SiteStaffr_Database::update_call_ai_summary( $call_id, $recap_html, '' );
            if ( false !== $result ) {
                SiteStaffr_Logger::legacy( "SiteStaffr AI Summary: Stored contact form recap for call $call_id" );
                return true;
            }
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: Failed to store contact form recap for call $call_id" );
            return new WP_Error( 'db_update_failed', "Failed to store contact form recap for call $call_id" );
        }

        // Get call transcripts
        $transcripts = SiteStaffr_Database::get_call_transcripts( $call_id );

        // Return error if no transcripts found (don't silently fail)
        if ( empty( $transcripts ) ) {
            $error_msg = "No transcripts found for call $call_id - cannot generate summary without conversation data";
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg" );
            return new WP_Error(
                'no_transcripts',
                $error_msg,
                array( 'call_id' => $call_id, 'status' => $call_status )
            );
        }

        // =====================================================================
        // COST OPTIMIZATION: Use fallback summary for instant hangup calls
        // =====================================================================
        // Detect calls with no meaningful conversation and skip expensive AI calls.
        // Criteria: duration < 10s OR transcript <= 3 entries OR no caller speech
        //
        // Estimated savings: reduction in recap API costs
        // for test calls, accidental dials, instant hangups, etc.
        $fallback_result = $this->check_instant_hangup_fallback( $call_id, $call_data, $transcripts );
        if ( $fallback_result !== false ) {
            // Instant hangup detected - use fallback summary (skip AI API calls)
            $result = SiteStaffr_Database::update_call_ai_summary(
                $call_id,
                $fallback_result['summary'],
                ''
            );

            if ( $result !== false ) {
                SiteStaffr_Logger::legacy( "SiteStaffr AI Summary: Stored fallback summary for call $call_id - {$fallback_result['reason']}" );
                return true;
            } else {
                $error_msg = "Database update failed for call $call_id - fallback summary generated but not saved";
                SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg" );
                return new WP_Error(
                    'db_update_failed',
                    $error_msg,
                    array( 'call_id' => $call_id )
                );
            }
        }

        // Validate transcript completeness (ensure both caller AND AI messages exist)
        $validation_result = $this->validate_transcript_completeness( $call_id, $transcripts );
        if ( is_wp_error( $validation_result ) ) {
            // Log warning but continue - partial transcripts better than no summary
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary WARNING: " . $validation_result->get_error_message() );
        }

        // Generate AI summary (pass test model if specified)
        $summary_result = $this->generate_call_summary( $call_id, $transcripts, $test_model, $contact_fields );

        // Check if summary generation failed
        if ( is_wp_error( $summary_result ) ) {
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: Failed to generate summary for call $call_id - " . $summary_result->get_error_message() );
            return $summary_result;
        }

        // Store in database
        if ( ! empty( $summary_result['summary'] ) ) {
            $result = SiteStaffr_Database::update_call_ai_summary(
                $call_id,
                $summary_result['summary'],
                ''
            );

            if ( $result !== false ) {
                $summary_length = strlen( $summary_result['summary'] );
                SiteStaffr_Logger::legacy( "SiteStaffr AI Summary SUCCESS: Stored recap for call $call_id (summary: {$summary_length} chars)" );
                return true;
            } else {
                $error_msg = "Database update failed for call $call_id - summary generated but not saved";
                SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg" );
                return new WP_Error(
                    'db_update_failed',
                    $error_msg,
                    array( 'call_id' => $call_id, 'summary_length' => strlen( $summary_result['summary'] ) )
                );
            }
        } else {
            $error_msg = "Summary generation returned empty result for call $call_id - API may have failed silently";
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg" );
            return new WP_Error(
                'empty_summary',
                $error_msg,
                array( 'call_id' => $call_id )
            );
        }
    }

    /**
     * Generate unified AI recap of call conversation.
     *
     * Uses a single middleware summary request and stores recap HTML in ai_summary.
     *
     * @since    1.0.0
     * @param    int      $call_id         The call ID.
     * @param    array    $transcript      Array of transcript objects.
     * @param    string   $test_model      Optional AI model override for testing.
     * @param    array    $contact_fields  Contact fields captured via function calling.
     * @return   array|WP_Error            Array with 'summary' key data on success, WP_Error on failure.
     */
    public function generate_call_summary( $call_id, $transcript, $test_model = null, $contact_fields = array() ) {
        // Validate transcript exists
        if ( empty( $transcript ) || ! is_array( $transcript ) ) {
            $error_msg = 'No transcript available for summary generation';
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg (call $call_id)" );
            return new WP_Error(
                'no_transcript',
                $error_msg,
                array( 'call_id' => $call_id )
            );
        }

        // Self-heal registration metadata before authenticated middleware calls.
        $this->maybe_ensure_site_registration();

        $effective_model = $this->resolve_summary_model( $test_model );

        // Format conversation for AI
        $conversation_text = array();
        foreach ( $transcript as $message ) {
            $speaker_label = $message->speaker === 'ai' ? 'AI' : 'Visitor';
            $conversation_text[] = $speaker_label . ': ' . $message->message_text;
        }

        $formatted_conversation = implode( "\n", $conversation_text );

        // Get site token for middleware authentication
        $site_token = get_option( 'sitestaffr_site_token' );
        if ( empty( $site_token ) ) {
            $site_token = wp_generate_password( 32, false );
            update_option( 'sitestaffr_site_token', $site_token );
        }

        // Get business context for AI (required by middleware)
        $settings = get_option( 'sitestaffr_settings', array() );
        // BUG-019 fix: Read business_hours from settings (same as AI client/prompt builder)
        // BUG-025 fix: Validate business hours to prevent corrupt data like "Minutes seconds"
        $business_hours = '';
        if ( isset( $settings['business_hours'] ) && ! empty( $settings['business_hours'] ) ) {
            $hours_lower = strtolower( trim( $settings['business_hours'] ) );
            $corrupt_patterns = array( 'minutes seconds', 'minutes', 'seconds' );

            if ( ! in_array( $hours_lower, $corrupt_patterns, true ) ) {
                $business_hours = $settings['business_hours'];
            } else {
                // Log corruption and skip corrupted hours
                SiteStaffr_Logger::legacy( 'SiteStaffr AI Summary Generator: Corrupt business hours detected and ignored: "' . $settings['business_hours'] . '"' );
                $business_hours = '';
            }
        }

        $business_info = array(
            'business_name'        => isset( $settings['business_name'] ) ? $settings['business_name'] : '',
            'business_hours'       => $business_hours,
            'business_description' => isset( $settings['business_context'] ) ? $settings['business_context'] : '',
            'business_phone'       => isset( $settings['business_phone'] ) ? $settings['business_phone'] : '',
        );

        // Generate recap via middleware summary endpoint.
        $summary_url = rtrim( SITESTAFFR_MIDDLEWARE_URL, '/' ) . '/api/v1/summary';
        $summary_prompt = "Generate a conversation recap for the following interaction.\n\nIMPORTANT: Only include contact details (phone, email, address, etc.) that the visitor actually provided. Do NOT include fields with \"Not provided\", \"N/A\", \"Unknown\", or similar placeholders. If a field was not collected, simply omit it entirely.\n\n" . $formatted_conversation;

        $timestamp = time(); // Unix timestamp for replay attack prevention
        $summary_request_body = array(
            'site_token'    => $site_token,
            'site_url'      => get_site_url(),
            'message'       => $summary_prompt,
            'business_info' => $business_info,
            'timestamp'     => $timestamp, // SECURITY: Timestamp for signature validation
        );
        if ( ! empty( $effective_model ) ) {
            $summary_request_body['model'] = $effective_model;
        }

        // Pass authoritative contact fields from function calling (source of truth for recap).
        $has_contact_fields = is_array( $contact_fields ) && array_filter( $contact_fields, 'strlen' );
        if ( $has_contact_fields ) {
            $summary_request_body['contact_fields'] = $contact_fields;
        }

        // SECURITY: Generate HMAC signature for request authentication
		$signature = $this->generate_request_signature( $summary_request_body, $timestamp );

        $start_time = microtime( true );
        $summary_response = wp_remote_post(
            $summary_url,
            array(
                'body'    => wp_json_encode( $summary_request_body ),
                'headers' => array(
                    'Content-Type'          => 'application/json',
                    'X-SiteStaffr-Signature' => $signature,
                    'X-SiteStaffr-Timestamp' => $timestamp,
                ),
                'timeout' => 30,
            )
        );

        $summary_text = '';
        if ( is_wp_error( $summary_response ) ) {
            $error_msg = 'Summary API request failed: ' . $summary_response->get_error_message();
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg (call $call_id)" );
            return new WP_Error(
                'api_error',
                $error_msg,
                array( 'call_id' => $call_id, 'type' => 'summary_request' )
            );
        }

        $response_code = wp_remote_retrieve_response_code( $summary_response );
        if ( $response_code !== 200 ) {
            $error_msg = "Summary API returned HTTP $response_code";
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg (call $call_id)" );
            $body = wp_remote_retrieve_body( $summary_response );
            return new WP_Error(
                'api_error',
                $error_msg,
                array( 'call_id' => $call_id, 'http_code' => $response_code, 'body' => $body )
            );
        }

        $body = wp_remote_retrieve_body( $summary_response );
        $data = json_decode( $body, true );

        if ( json_last_error() !== JSON_ERROR_NONE ) {
            $error_msg = 'Failed to parse summary API response JSON: ' . json_last_error_msg();
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg (call $call_id)" );
            return new WP_Error(
                'json_error',
                $error_msg,
                array( 'call_id' => $call_id, 'body' => substr( $body, 0, 500 ) )
            );
        }

        if ( isset( $data['success'] ) && $data['success'] && isset( $data['ai_response'] ) ) {
            $summary_text = trim( $data['ai_response'] );
            $elapsed = round( ( microtime( true ) - $start_time ) * 1000 );

            // Log token usage for unified recap generation.
            if ( isset( $data['usage'] ) ) {
                $this->log_summary_tokens( $call_id, $data['usage'], 'recap', $elapsed );
            }

            // Strip markdown code blocks if present (AI sometimes wraps text in ```...```)
            $summary_text = $this->strip_markdown_code_block( $summary_text );
            $summary_text = $this->strip_redundant_recap_heading( $summary_text );
            $summary_text = $this->normalize_summary_name_inference( $summary_text, $transcript );
            $summary_text = $this->normalize_emails_to_lowercase( $summary_text );
            $summary_text = $this->sanitize_recap_html( $summary_text );
        } else {
            $error_msg = 'Summary API response missing success/ai_response fields';
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary ERROR: $error_msg (call $call_id)" );
            return new WP_Error(
                'invalid_response',
                $error_msg,
                array( 'call_id' => $call_id, 'data' => $data )
            );
        }

        return array(
            'summary' => $summary_text,
        );
    }

    /**
     * Validate that transcript contains both caller and AI messages.
     *
     * Checks for transcript completeness to detect potential issues like:
     * - Only caller messages (AI responses not stored - BUG-004)
     * - Only AI messages (caller messages missing)
     * - Empty transcript (no conversation data)
     *
     * @since    1.0.0
     * @param    int      $call_id      The call ID for logging.
     * @param    array    $transcript   Array of transcript objects.
     * @return   bool|WP_Error         True if complete, WP_Error if incomplete (non-fatal).
     */
    private function validate_transcript_completeness( $call_id, $transcript ) {
        if ( empty( $transcript ) || ! is_array( $transcript ) ) {
            return new WP_Error(
                'empty_transcript',
                "Call $call_id has empty transcript array",
                array( 'call_id' => $call_id )
            );
        }

        // Count message types
        $caller_count = 0;
        $ai_count = 0;

        foreach ( $transcript as $message ) {
            if ( ! isset( $message->speaker ) ) {
                continue;
            }

            if ( $message->speaker === 'caller' ) {
                $caller_count++;
            } elseif ( $message->speaker === 'ai' ) {
                $ai_count++;
            }
        }

        // Check for missing AI responses (indicates BUG-004 or similar issue)
        if ( $caller_count > 0 && $ai_count === 0 ) {
            return new WP_Error(
                'incomplete_transcript',
                "Call $call_id has only caller messages ($caller_count), no AI responses - may indicate storage issue",
                array( 'call_id' => $call_id, 'caller_count' => $caller_count, 'ai_count' => 0 )
            );
        }

        // Check for missing caller messages (unusual but possible)
        if ( $ai_count > 0 && $caller_count === 0 ) {
            return new WP_Error(
                'incomplete_transcript',
                "Call $call_id has only AI messages ($ai_count), no caller messages",
                array( 'call_id' => $call_id, 'caller_count' => 0, 'ai_count' => $ai_count )
            );
        }

        // Check for completely empty transcript
        if ( $caller_count === 0 && $ai_count === 0 ) {
            return new WP_Error(
                'empty_transcript',
                "Call $call_id has transcript records but no recognizable messages",
                array( 'call_id' => $call_id, 'total_records' => count( $transcript ) )
            );
        }

        return true;
    }

    /**
     * Get API secret from WordPress options.
     *
     * SECURITY: Retrieves the API secret from WordPress options table.
     * Auto-generates a new secret if one doesn't exist (for edge cases).
     *
     * @since 0.10.101
     * @return string API secret for HMAC signing.
     */
    private function get_api_secret() {
        // SECURITY: Prefer per-site secret (v0.13.72+), fall back to global secret
        // This supports gradual migration from global to per-site secrets

        // Try per-site secret first (registered sites)
        $site_api_secret = get_option( 'sitestaffr_site_api_secret' );
        if ( ! empty( $site_api_secret ) ) {
            return $site_api_secret;
        }

        // Fall back to global secret (backward compatibility)
        $api_secret = get_option( 'sitestaffr_api_secret' );

        // Auto-generate if missing (should not happen after activation, but safety check)
        if ( empty( $api_secret ) ) {
            $api_secret = bin2hex( random_bytes( 32 ) );
            update_option( 'sitestaffr_api_secret', $api_secret );
            SiteStaffr_Logger::legacy( 'SiteStaffr AI Summary Generator: Auto-generated missing API secret (this should not happen - check activation hook)' );
        }

        return $api_secret;
    }

    /**
     * Generate HMAC signature for request authentication.
     *
     * SECURITY: This prevents unauthorized access to the middleware by signing requests.
     * The middleware validates this signature using the same secret.
     *
     * Signature calculation:
     * 1. JSON encode the request body (ensures consistent serialization)
     * 2. Generate HMAC-SHA256 using API secret from WordPress options
     * 3. Encode as base64 for transmission
     *
     * @since 0.9.49
     * @updated 0.10.101 - Use get_api_secret() instead of hardcoded constant
	 * @param array $body      Request body to sign.
	 * @param int   $timestamp Request timestamp used by X-SiteStaffr-Timestamp.
	 * @return string Base64-encoded HMAC signature.
	 */
	private function generate_request_signature( $body, $timestamp ) {
		// JSON encode for consistent string representation
		$payload = wp_json_encode( $body );
		$payload_to_sign = (string) $timestamp . '.' . $payload;

		// Generate HMAC-SHA256 signature using secret from WordPress options
		$signature = hash_hmac( 'sha256', $payload_to_sign, $this->get_api_secret(), true );

        // Encode as base64 for HTTP header transmission
        return base64_encode( $signature );
    }

    /**
     * Prevent inferred caller names in summary text when no explicit name was provided.
     *
     * @since 0.13.82
     * @param string $summary_text Generated summary text.
     * @param array  $transcript   Conversation transcript entries.
     * @return string
     */
    private function normalize_summary_name_inference( $summary_text, $transcript ) {
        if ( empty( $summary_text ) || empty( $transcript ) || ! is_array( $transcript ) ) {
            return $summary_text;
        }

        $caller_name = $this->get_explicit_caller_name( $transcript );
        if ( ! empty( $caller_name ) ) {
            $normalized = trim( $summary_text );
            if ( preg_match( '/^Visitor called\b/u', $normalized ) ) {
                return preg_replace( '/^Visitor called\b/u', $caller_name . ' called', $normalized, 1 );
            }
            return $summary_text;
        }

        if ( $this->caller_explicitly_provided_name( $transcript ) ) {
            return $summary_text;
        }

        $normalized = trim( $summary_text );

        // If no explicit name exists, summary must not start with guessed proper name.
        // Convert "Name called ..." -> "Visitor called ..."
        if ( preg_match( '/^[A-Z][A-Za-z\'-]{1,40}\s+called\b/u', $normalized ) ) {
            $normalized = preg_replace( '/^[A-Z][A-Za-z\'-]{1,40}\s+called\b/u', 'Visitor called', $normalized, 1 );
        }

        return $normalized;
    }

    /**
     * Detect whether caller explicitly stated their name in transcript.
     *
     * @since 0.13.82
     * @param array $transcript Conversation transcript entries.
     * @return bool
     */
    private function caller_explicitly_provided_name( $transcript ) {
        $messages = array_values( is_array( $transcript ) ? $transcript : array() );

        foreach ( $messages as $index => $message ) {
            if ( ! isset( $message->speaker, $message->message_text ) ) {
                continue;
            }

            if ( 'caller' !== $message->speaker ) {
                continue;
            }

            $text = strtolower( trim( $message->message_text ) );
            if ( empty( $text ) ) {
                continue;
            }

            if (
                preg_match( '/\bmy name is\b/', $text ) ||
                preg_match( '/\bthis is\b/', $text ) ||
                preg_match( '/\bi am\b/', $text ) ||
                preg_match( '/\bi\'m\b/', $text )
            ) {
                return true;
            }

            // Accept direct bare-name replies when the AI just asked for name.
            if ( $this->is_plausible_name_only_reply( $text ) && $this->previous_ai_message_requests_name( $messages, $index ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Determine whether caller text is a plausible standalone name reply.
     *
     * Examples accepted: "alice", "alice jones", "o'neil".
     * Examples rejected: "yes", "that's it", emails, numbers.
     *
     * @since 0.13.86
     * @param string $text Lowercased caller text.
     * @return bool
     */
    private function is_plausible_name_only_reply( $text ) {
        $normalized = trim( strtolower( (string) $text ) );
        if ( '' === $normalized ) {
            return false;
        }

        // Accept short natural punctuation in direct replies: "Mario?", "Alice."
        $normalized = preg_replace( '/[\\?\\!\\.,;:]+$/', '', $normalized );
        $normalized = trim( $normalized );
        if ( '' === $normalized ) {
            return false;
        }

        // Strip common conversational prefixes.
        $normalized = preg_replace( '/^(it\'s|its|this is)\s+/', '', $normalized );
        $normalized = preg_replace( '/^i\s*am\s+/', '', $normalized );
        $normalized = preg_replace( '/^i\'m\s+/', '', $normalized );
        $normalized = trim( $normalized );

        // Reject obvious non-name replies.
        $invalid_replies = array(
            'yes',
            'yeah',
            'yep',
            'no',
            'nope',
            'that is it',
            "that's it",
            'thats it',
            'n a',
            'na',
            'none',
            'unknown',
            'prefer not',
            'prefer not to say',
        );
        if ( in_array( $normalized, $invalid_replies, true ) ) {
            return false;
        }

        // Reject emails, numbers, or long free-form phrases.
        if ( preg_match( '/@|\d/', $normalized ) ) {
            return false;
        }
        if ( str_word_count( $normalized ) > 3 ) {
            return false;
        }

        // Allow simple first/last names with apostrophes and hyphens.
        return (bool) preg_match( '/^[a-z][a-z\'-]*(?:\s+[a-z][a-z\'-]*){0,2}$/', $normalized );
    }

    /**
     * Check whether the most recent AI turn before a caller reply asked for caller name.
     *
     * @since 0.13.86
     * @param array $messages Transcript entries.
     * @param int   $caller_index Index of caller message in transcript.
     * @return bool
     */
    private function previous_ai_message_requests_name( $messages, $caller_index ) {
        if ( $caller_index <= 0 ) {
            return false;
        }

        // Look back a few turns to find the nearest AI question.
        for ( $i = $caller_index - 1; $i >= 0 && $i >= ( $caller_index - 3 ); $i-- ) {
            $entry = $messages[ $i ];
            if ( ! isset( $entry->speaker, $entry->message_text ) || 'ai' !== $entry->speaker ) {
                continue;
            }

            $ai_text = strtolower( trim( $entry->message_text ) );
            if ( '' === $ai_text ) {
                continue;
            }

            return (
                false !== strpos( $ai_text, 'your name' ) ||
                false !== strpos( $ai_text, 'what name' ) ||
                false !== strpos( $ai_text, 'who am i speaking with' ) ||
                false !== strpos( $ai_text, 'what should i call you' ) ||
                false !== strpos( $ai_text, 'team use for this request' )
            );
        }

        return false;
    }

    /**
     * Extract explicit caller name from transcript when available.
     *
     * @since 0.13.88
     * @param array $transcript Conversation transcript entries.
     * @return string Empty string when no explicit name found.
     */
    private function get_explicit_caller_name( $transcript ) {
        $messages = array_values( is_array( $transcript ) ? $transcript : array() );
        $found_name = '';

        foreach ( $messages as $index => $message ) {
            if ( ! isset( $message->speaker, $message->message_text ) || 'caller' !== $message->speaker ) {
                continue;
            }

            $raw_text = trim( (string) $message->message_text );
            if ( '' === $raw_text ) {
                continue;
            }

            $raw_text_no_punct = preg_replace( '/[\\?\\!\\.,;:]+$/', '', $raw_text );
            $raw_text_no_punct = trim( (string) $raw_text_no_punct );

            if ( preg_match( '/(?:my name is|this is|i am|i\'m)\s+([a-z][a-z\'-]*(?:\s+[a-z][a-z\'-]*){0,2})/i', $raw_text, $matches ) ) {
                $candidate = $this->normalize_caller_name( $matches[1] );
                if ( '' !== $candidate ) {
                    $found_name = $candidate;
                }
                continue;
            }

            $lower_text = strtolower( $raw_text_no_punct );
            if ( $this->is_plausible_name_only_reply( $lower_text ) && $this->previous_ai_message_requests_name( $messages, $index ) ) {
                $candidate = preg_replace( '/^(it\'s|its|this is)\s+/i', '', $raw_text_no_punct );
                $candidate = preg_replace( '/^i\s*am\s+/i', '', $candidate );
                $candidate = preg_replace( '/^i\'m\s+/i', '', $candidate );
                $candidate = $this->normalize_caller_name( $candidate );
                if ( '' !== $candidate ) {
                    $found_name = $candidate;
                }
            }
        }

        return $found_name;
    }

    /**
     * Normalize caller name for recap output.
     *
     * @since 0.13.88
     * @param string $name Candidate name text.
     * @return string
     */
    private function normalize_caller_name( $name ) {
        $normalized = trim( (string) $name );
        $normalized = preg_replace( '/[^A-Za-z\'\-\s]/', '', $normalized );
        $normalized = preg_replace( '/\s+/', ' ', $normalized );
        $normalized = trim( $normalized );

        if ( '' === $normalized ) {
            return '';
        }

        if ( str_word_count( $normalized ) > 3 ) {
            return '';
        }

        return ucwords( strtolower( $normalized ) );
    }

    /**
     * Normalize all email addresses in text to lowercase.
     *
     * @since 0.13.90
     * @param string $text Text that may contain email addresses.
     * @return string
     */
    private function normalize_emails_to_lowercase( $text ) {
        if ( empty( $text ) ) {
            return $text;
        }

        return preg_replace_callback(
            '/\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/i',
            function( $matches ) {
                return strtolower( $matches[0] );
            },
            $text
        );
    }

    /**
     * Sanitize unified recap HTML output.
     *
     * Allows only minimal formatting tags needed by recap rendering.
     *
     * @since 0.14.122
     * @param string $text Recap HTML from AI.
     * @return string
     */
    private function sanitize_recap_html( $text ) {
        if ( empty( $text ) ) {
            return '';
        }

        $allowed_tags = array(
            'strong' => array(),
            'ul'     => array(),
            'li'     => array(),
            'br'     => array(),
            'p'      => array(),
            'a'      => array(
                'href'   => array(),
                'target' => array(),
                'rel'    => array(),
            ),
        );

        return wp_kses( (string) $text, $allowed_tags );
    }

    /**
     * Remove redundant recap headings from model output.
     *
     * Prevents duplicate "Conversation Recap" text inside content when the UI
     * already provides that section title.
     *
     * @since 0.14.138
     * @param string $text Raw recap text.
     * @return string
     */
    private function strip_redundant_recap_heading( $text ) {
        $value = trim( (string) $text );
        if ( '' === $value ) {
            return $value;
        }

        // Remove plain or wrapped leading heading variants once.
        $value = preg_replace(
            '/^\s*(?:<p>\s*)?(?:<strong>\s*)?conversation\s+recap\s*:?(?:\s*<\/strong>)?(?:\s*<\/p>)?\s*/i',
            '',
            $value,
            1
        );

        // Trim leading line break artifacts after heading removal.
        $value = preg_replace( '/^(?:<br\s*\/?>\s*)+/i', '', (string) $value, 1 );

        return trim( (string) $value );
    }

    /**
     * Strip markdown code block wrapper from AI response.
     *
     * AI sometimes returns JSON wrapped in ```json...``` markdown code blocks.
     * This method removes that wrapper to allow proper JSON parsing.
     *
     * @since    0.10.92
     * @param    string    $text    Raw AI response text.
     * @return   string             Clean JSON string without markdown wrapper.
     */
    private function strip_markdown_code_block( $text ) {
        $text = trim( $text );

        // Check if wrapped in ```json ... ``` or ``` ... ```
        if ( preg_match( '/^```(?:json)?\s*\n?(.*?)\n?```$/s', $text, $matches ) ) {
            return trim( $matches[1] );
        }

        return $text;
    }

    /**
     * Check if call qualifies for instant hangup fallback summary.
     *
     * COST OPTIMIZATION: Detect calls with no meaningful conversation and avoid
     * expensive AI API calls for recap generation.
     *
     * Detection criteria (ANY of these triggers fallback):
     * 1. Call duration < 10 seconds (instant hangup)
     * 2. Transcript has <= 3 entries (just greeting + maybe 1-2 empty responses)
     * 3. No caller speech detected (all transcript entries are from AI/system)
     *
     * Estimated savings: ~20-30% reduction in summary API costs for test calls,
     * accidental dials, wrong numbers, and instant hangups.
     *
     * @since 0.11.53
     * @param int    $call_id      The call ID for logging.
     * @param object $call_data    Call record with call_duration and started_at fields.
     * @param array  $transcripts  Array of transcript objects.
     * @return array|false         Array with 'summary' and 'reason' if fallback should be used, false otherwise.
     */
    private function check_instant_hangup_fallback( $call_id, $call_data, $transcripts ) {
        // Get call duration (in seconds)
        $call_duration = isset( $call_data->call_duration ) ? intval( $call_data->call_duration ) : 0;

        // Count transcript entries
        $transcript_count = count( $transcripts );

        // Check for caller speech
        $has_caller_speech = false;
        foreach ( $transcripts as $entry ) {
            if ( $entry->speaker === 'caller' && ! empty( trim( $entry->message_text ) ) ) {
                $has_caller_speech = true;
                break;
            }
        }

        // =====================================================================
        // RULE 1: Instant hangup (< 10 seconds)
        // =====================================================================
        if ( $call_duration > 0 && $call_duration < 10 ) {
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary: Call $call_id is instant hangup ({$call_duration}s) - using fallback summary (cost optimization)" );
            return array(
                'summary'  => 'Call ended immediately - no information collected',
                'reason'   => 'instant hangup (< 10 seconds)',
            );
        }

        // =====================================================================
        // RULE 2: Minimal transcript (≤ 2 entries)
        // =====================================================================
        if ( $transcript_count <= 2 ) {
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary: Call $call_id has minimal transcript ({$transcript_count} entries) - using fallback summary (cost optimization)" );
            return array(
                'summary'  => 'Call ended immediately - no information collected',
                'reason'   => 'minimal transcript (≤ 2 entries)',
            );
        }

        // =====================================================================
        // RULE 3: No caller speech detected
        // =====================================================================
        if ( ! $has_caller_speech ) {
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary: Call $call_id has no caller speech - using fallback summary (cost optimization)" );
            return array(
                'summary'  => 'Visitor hung up without speaking',
                'reason'   => 'no visitor speech detected',
            );
        }

        // No fallback conditions met - continue with normal AI summary generation
        return false;
    }

    /**
     * Build recap HTML directly from onboarding contact fields (no AI API call needed).
     *
     * @since 1.4.0
     * @param array $contact_fields Captured onboarding fields.
     * @return string HTML recap.
     */
    private function build_onboarding_recap_html( $contact_fields ) {
        $field_labels = array(
            'name'          => __( 'Contact Name', 'sitestaffr' ),
            'business_name' => __( 'Business Name', 'sitestaffr' ),
            'website_url'   => __( 'Website', 'sitestaffr' ),
            'email'         => __( 'Email', 'sitestaffr' ),
            'phone'         => __( 'Phone', 'sitestaffr' ),
        );

        $rows = '';
        foreach ( $field_labels as $key => $label ) {
            $raw_value = isset( $contact_fields[ $key ] ) ? trim( $contact_fields[ $key ] ) : '';
            if ( '' === $raw_value ) {
                continue;
            }

            // Linkify email and phone values.
            if ( 'email' === $key && is_email( $raw_value ) ) {
                $value_html = '<a href="mailto:' . esc_attr( $raw_value ) . '">' . esc_html( $raw_value ) . '</a>';
            } elseif ( 'phone' === $key ) {
                $clean_phone = preg_replace( '/[^\d+]/', '', $raw_value );
                $value_html  = '<a href="tel:' . esc_attr( $clean_phone ) . '">' . esc_html( $raw_value ) . '</a>';
            } elseif ( 'website_url' === $key ) {
                // Default to https:// if no protocol specified.
                $url_with_scheme = $raw_value;
                if ( ! preg_match( '/^https?:\/\//i', $url_with_scheme ) ) {
                    $url_with_scheme = 'https://' . $url_with_scheme;
                }
                $url = esc_url( $url_with_scheme );
                $value_html = '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . esc_html( $raw_value ) . '</a>';
            } else {
                $value_html = esc_html( $raw_value );
            }

            $rows .= '<tr><td style="padding:6px 12px 6px 0;font-weight:600;vertical-align:top;">' . esc_html( $label ) . '</td>';
            $rows .= '<td style="padding:6px 0;">' . $value_html . '</td></tr>';
        }

        if ( '' === $rows ) {
            return '<p>' . esc_html__( 'Onboarding conversation — no fields captured.', 'sitestaffr' ) . '</p>';
        }

        return '<table style="border-collapse:collapse;width:100%;">' . $rows . '</table>';
    }

    /**
     * Build recap for free contact form fallback submissions.
     *
     * Calls the standard AI summary endpoint to get narrative + follow-up for
     * the full conversation transcript. Then injects contact fields (name, email,
     * phone, etc.) as "Key: Value" lines so the shared recap formatter renders
     * them as bullet points — matching the styling of all other recaps.
     *
     * Unknown field keys are rendered with a humanized label so future custom
     * form fields surface automatically without additional code changes.
     *
     * @since 1.14.28
     * @param int         $call_id        Call ID (used for AI summary generation).
     * @param array       $contact_fields Sanitized contact fields from the form.
     * @param string|null $test_model     Optional AI model override.
     * @return string                     Plain-text recap (run through format helper before display).
     */
    private function build_contact_form_recap_html( $call_id, $contact_fields, $test_model = null ) {
        // Known-field label map (order determines display order).
        // 'reason' is the free-form message — excluded from structured fields
        // because the AI narrative covers it.
        $field_labels = array(
            'name'          => 'Name',
            'email'         => 'Email',
            'phone'         => 'Phone',
            'business_name' => 'Business Name',
            'website_url'   => 'Website',
        );
        $message_field_key  = 'reason';
        $excluded_meta_keys = array( $message_field_key );

        // Build "Key: Value" lines for known fields (predictable order).
        $field_lines = array();
        foreach ( $field_labels as $key => $label ) {
            $raw_value = isset( $contact_fields[ $key ] ) ? trim( (string) $contact_fields[ $key ] ) : '';
            if ( '' !== $raw_value ) {
                $field_lines[] = $label . ': ' . $raw_value;
            }
        }

        // Append any extra/custom fields not in the known map.
        if ( is_array( $contact_fields ) ) {
            foreach ( $contact_fields as $key => $raw_value ) {
                if ( isset( $field_labels[ $key ] ) || in_array( $key, $excluded_meta_keys, true ) ) {
                    continue;
                }
                $raw_value = trim( (string) $raw_value );
                if ( '' === $raw_value ) {
                    continue;
                }
                $label = ucwords( str_replace( array( '_', '-' ), ' ', (string) $key ) );
                $field_lines[] = $label . ': ' . $raw_value;
            }
        }

        // Generate AI summary from full transcript + contact_fields context.
        $ai_summary = '';
        $transcripts = SiteStaffr_Database::get_call_transcripts( $call_id );
        if ( ! empty( $transcripts ) ) {
            $summary_result = $this->generate_call_summary( $call_id, $transcripts, $test_model, $contact_fields );
            if ( ! is_wp_error( $summary_result ) && ! empty( $summary_result['summary'] ) ) {
                $ai_summary = trim( (string) $summary_result['summary'] );
            }
        }

        // Inject contact field lines into the AI summary so the shared recap
        // formatter renders them as bullet points alongside any detail lines
        // the AI produced. Fields are inserted right before "Suggested follow-up:"
        // (if present), or appended at the end.
        $combined = $ai_summary;
        if ( ! empty( $field_lines ) ) {
            $injected = implode( "\n", $field_lines );

            // Strip any contact-field lines the AI already included to avoid duplication.
            foreach ( $field_labels as $key => $label ) {
                if ( '' !== $label && false !== stripos( $combined, $label . ':' ) ) {
                    // Remove the matching injected line.
                    $injected = preg_replace( '/^' . preg_quote( $label, '/' ) . ':.*$/mi', '', $injected );
                }
            }
            $injected = trim( preg_replace( '/\n{2,}/', "\n", $injected ) );

            if ( '' !== $injected ) {
                // Insert before the "Suggested follow-up:" line if present.
                $followup_pos = stripos( $combined, 'Suggested follow-up:' );
                if ( false !== $followup_pos ) {
                    $combined = substr( $combined, 0, $followup_pos ) . $injected . "\n" . substr( $combined, $followup_pos );
                } else {
                    $combined = ( '' !== $combined ) ? $combined . "\n" . $injected : $injected;
                }
            }
        }

        $combined = trim( $combined );
        if ( '' === $combined ) {
            return 'Contact form submission — no fields captured.';
        }

        return $combined;
    }

    /**
     * Log token usage for recap generation.
     *
     * Stores token metrics in wp_sitestaffr_ai_metadata table for cost tracking and diagnostics.
     * This enables the SiteStaffr support team to monitor API costs per call.
     *
     * @since 0.11.56
     * @param int    $call_id         Call ID from wp_sitestaffr_calls table.
     * @param array  $usage           Token usage data from API response (contains input_tokens, output_tokens).
     * @param string $operation       Type of operation (for example: 'recap').
     * @param int    $response_time   API response time in milliseconds.
     * @return bool                   True on success, false on failure.
     */
    private function log_summary_tokens( $call_id, $usage, $operation, $response_time = 0 ) {
        global $wpdb;

        $table_name = $wpdb->prefix . 'phoneease_ai_metadata';

        // Extract token counts from usage data
        $input_tokens = isset( $usage['input_tokens'] ) ? intval( $usage['input_tokens'] ) : 0;
        $output_tokens = isset( $usage['output_tokens'] ) ? intval( $usage['output_tokens'] ) : 0;
        $cached_tokens = isset( $usage['cache_read_input_tokens'] ) ? intval( $usage['cache_read_input_tokens'] ) : 0;
        $total_tokens = $input_tokens + $output_tokens + $cached_tokens;

        // Extract model name (fallback to generic label when middleware omits model)
        $model = isset( $usage['model'] ) ? sanitize_text_field( $usage['model'] ) : self::MODEL_FALLBACK;

        $data = array(
            'call_id'              => $call_id,
            'prompt_tokens'        => $input_tokens,
            'completion_tokens'    => $output_tokens,
            'cached_tokens'        => $cached_tokens,
            'total_tokens'         => $total_tokens,
            'model_used'           => $model,
            'operation_type'       => $operation,
            'api_response_time_ms' => $response_time,
            'created_at'           => current_time( 'mysql' ),
        );

        $result = $wpdb->insert(
            $table_name,
            $data,
            array( '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%s' )
        );

        if ( $result === false ) {
            SiteStaffr_Logger::legacy( "SiteStaffr: Failed to log {$operation} tokens for call {$call_id} - " . $wpdb->last_error );
            return false;
        }

        return true;
    }

    /**
     * Resolve optional summary model override.
     *
     * @since 0.13.77
     * @param string|null $requested_model Optional override from diagnostics/testing.
     * @return string
     */
    private function resolve_summary_model( $requested_model = null ) {
        if ( empty( $requested_model ) ) {
            return '';
        }

        $normalized = strtolower( trim( $requested_model ) );
        if ( strpos( $normalized, 'claude-' ) === 0 ) {
            SiteStaffr_Logger::legacy( "SiteStaffr AI Summary: Ignoring deprecated model override '{$requested_model}' and using middleware defaults" );
            return '';
        }

        return $requested_model;
    }

    /**
     * Attempt to refresh site registration when per-site auth metadata is missing.
     *
     * @since 0.13.78
     * @return void
     */
    private function maybe_ensure_site_registration() {
        $site_registered = (bool) get_option( 'sitestaffr_site_registered', false );
        $site_api_secret = get_option( 'sitestaffr_site_api_secret' );

        if ( $site_registered && ! empty( $site_api_secret ) ) {
            return;
        }

        if ( ! class_exists( 'SiteStaffr_Site_Registration' ) ) {
            require_once SITESTAFFR_PLUGIN_DIR . 'includes/class-sitestaffr-site-registration.php';
        }

        if ( class_exists( 'SiteStaffr_Site_Registration' ) ) {
            $result = SiteStaffr_Site_Registration::register_site();
            if ( ! $result ) {
                SiteStaffr_Logger::legacy( 'SiteStaffr AI Summary: Site registration refresh failed (continuing with existing credentials)' );
            }
        }
    }

}
// phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
