Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.54% covered (success)
94.54%
779 / 824
76.74% covered (warning)
76.74%
66 / 86
CRAP
0.00% covered (danger)
0.00%
0 / 1
Feedback
94.54% covered (success)
94.54%
779 / 824
76.74% covered (warning)
76.74%
66 / 86
285.14
0.00% covered (danger)
0.00%
0 / 1
 get_all_source_post_ids
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
2
 get_source_filter_sql
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 invalidate_source_ids_cache_on_delete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_backfill_source_meta
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 get
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 clear_cache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_from_post
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
9
 from_submission
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 set_source
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load_from_submission
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 get_field_value
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
8.50
 process_file_field_value
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 process_image_select_field_value
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 process_radio_field_value
75.76% covered (warning)
75.76%
25 / 33
0.00% covered (danger)
0.00%
0 / 1
12.72
 get_field_value_by_label
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_first_field_of_type
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_fields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_field_type
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_entry_values
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 get_all_values
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_form_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_legacy_extra_values
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
14
 get_all_legacy_values
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 get_compiled_fields
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
15
 get_feedback_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_time
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_akismet_vars
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 get_author
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_first_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_last_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_email
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_avatar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_author_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_comment_content
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_ip_address
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_user_agent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_country_code
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_country_flag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_country_code_from_ip
68.97% covered (warning)
68.97%
20 / 29
0.00% covered (danger)
0.00%
0 / 1
12.99
 geolocate_via_api
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 get_browser
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 get_logged_in_user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_subject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_notification_recipients
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_consent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_file
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_unread
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mark_as_read
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 mark_as_unread
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 get_unread_count
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 get_files
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 get_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_entry_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_entry_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_entry_permalink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_edit_form_url
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_entry_short_permalink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 serialize
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 parse_content
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parse_content_v2
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
8.09
 parse_content_v3
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 parse_legacy_content
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 fix_malformed_json
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
2
 split_legacy_content
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 extract_legacy_values
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 extract_legacy_lines
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parse_json_data
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 parse_array_format
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 process_legacy_lines
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
5
 is_legacy_file_upload
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 process_legacy_values
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
5
 add_comment_content_field
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 extract_label_from_key
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 get_field_meta
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
9.66
 get_computed_fields
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 get_computed_subject
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_computed_comment_content
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_computed_consent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_computed_notification_recipients
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validate_notification_recipients
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 get_field_by_form_field_id
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 get_field_value_by_form_field_id
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Feedback class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Device_Detection\User_Agent_Info;
12use Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
13use WP_Post;
14/**
15 * Handles the response for a contact form submission.
16 *
17 * Feedback objects are there to help us interact with the form response data.
18 */
19class Feedback {
20    use Country_Code_Utils;
21
22    const POST_TYPE = 'feedback';
23
24    /**
25     * Comment status for unread feedback.
26     *
27     * @var string
28     */
29    public const STATUS_UNREAD = 'open';
30
31    /**
32     * Comment status for read feedback.
33     *
34     * @var string
35     */
36    public const STATUS_READ = 'closed';
37
38    /**
39     * Meta key used to store the source post ID on feedback posts.
40     *
41     * @var string
42     */
43    public const SOURCE_META_KEY = '_feedback_source_post_id';
44
45    /**
46     * Cache key for the source post IDs list.
47     *
48     * @var string
49     */
50    private const SOURCE_IDS_CACHE_KEY = 'jetpack_forms_source_post_ids';
51
52    /**
53     * Cache group for forms data.
54     *
55     * @var string
56     */
57    private const CACHE_GROUP = 'jetpack_forms';
58
59    /**
60     * Returns all distinct source post IDs for feedback entries.
61     *
62     * Uses the _feedback_source_post_id meta for new feedback, with a fallback
63     * to post_parent for old feedback that doesn't have the meta yet (excluding
64     * jetpack_form parents).
65     *
66     * @return array Array of unique source post IDs.
67     */
68    public static function get_all_source_post_ids() {
69        $source_ids = wp_cache_get( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
70
71        if ( false !== $source_ids ) {
72            return $source_ids;
73        }
74
75        global $wpdb;
76
77        $meta_key     = self::SOURCE_META_KEY;
78        $statuses     = array( 'draft', 'publish', 'spam', 'trash' );
79        $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) );
80
81        $post_type = self::POST_TYPE;
82
83        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
84        $source_ids = $wpdb->get_col(
85            $wpdb->prepare(
86                "SELECT DISTINCT source_id FROM (
87                    SELECT CAST(pm.meta_value AS UNSIGNED) AS source_id
88                    FROM {$wpdb->postmeta} pm
89                    INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
90                    WHERE pm.meta_key = %s
91                    AND p.post_type = %s
92                    AND p.post_status IN ({$placeholders})
93                    AND pm.meta_value != '0' AND pm.meta_value != ''
94                UNION
95                    SELECT p.post_parent AS source_id
96                    FROM {$wpdb->posts} p
97                    LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s
98                    LEFT JOIN {$wpdb->posts} parent_post ON parent_post.ID = p.post_parent
99                    WHERE p.post_type = %s
100                    AND p.post_status IN ({$placeholders})
101                    AND p.post_parent > 0
102                    AND pm.meta_id IS NULL
103                    AND (parent_post.post_type IS NULL OR parent_post.post_type != %s)
104                ) AS combined_sources",
105                array_merge(
106                    array( $meta_key, $post_type ),
107                    $statuses,
108                    array( $meta_key, $post_type ),
109                    $statuses,
110                    array( Contact_Form::POST_TYPE )
111                )
112            )
113        );
114        // phpcs:enable
115
116        $source_ids = array_map( 'intval', $source_ids );
117        wp_cache_set( self::SOURCE_IDS_CACHE_KEY, $source_ids, self::CACHE_GROUP, HOUR_IN_SECONDS );
118
119        return $source_ids;
120    }
121
122    /**
123     * Returns the JOIN and WHERE SQL fragments for filtering feedback posts by source post ID.
124     *
125     * Matches feedback with the _feedback_source_post_id meta set, or falls back
126     * to post_parent for old feedback that doesn't have the meta yet.
127     *
128     * @since $$next-version$$
129     *
130     * @param int $source_id The source post ID to filter by.
131     * @return array{join: string, where: string} SQL fragments.
132     */
133    public static function get_source_filter_sql( $source_id ) {
134        global $wpdb;
135        $meta_key  = esc_sql( self::SOURCE_META_KEY );
136        $source_id = (int) $source_id;
137        return array(
138            'join'  => " LEFT JOIN {$wpdb->postmeta} AS source_meta ON ({$wpdb->posts}.ID = source_meta.post_id AND source_meta.meta_key = '{$meta_key}')",
139            'where' => $wpdb->prepare(
140                "(source_meta.meta_value = %s OR (source_meta.meta_id IS NULL AND {$wpdb->posts}.post_parent = %d))",
141                (string) $source_id,
142                $source_id
143            ),
144        );
145    }
146
147    /**
148     * Invalidates the source post IDs cache when a feedback post is deleted.
149     *
150     * @param int      $post_id The deleted post ID.
151     * @param \WP_Post $post    The deleted post object.
152     */
153    public static function invalidate_source_ids_cache_on_delete( $post_id, $post ) {
154        if ( $post->post_type === self::POST_TYPE ) {
155            wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
156        }
157    }
158
159    /**
160     * Backfills the source post ID meta from the feedback object's resolved source.
161     *
162     * For old feedback parented to a jetpack_form that doesn't have
163     * _feedback_source_post_id set yet, this writes the meta so future
164     * queries can filter by source without the post_parent fallback.
165     *
166     * @param int      $post_id  The feedback post ID.
167     * @param Feedback $feedback The feedback object (already has source resolved from parsed content).
168     */
169    public static function maybe_backfill_source_meta( $post_id, $feedback ) {
170        $existing = get_post_meta( $post_id, self::SOURCE_META_KEY, true );
171        if ( $existing ) {
172            return;
173        }
174
175        $source_id = $feedback->get_entry_id();
176        if ( is_numeric( $source_id ) && (int) $source_id > 0 ) {
177            $meta_added = add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
178            if ( $meta_added ) {
179                wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
180            }
181        }
182    }
183
184    /**
185     * The form field values.
186     *
187     * @var array
188     */
189    protected $fields = array();
190
191    /**
192     * Static cache for feedback fields.
193     *
194     * This is used to avoid recomputing the feedback fields for the same post ID.
195     *
196     * @var array
197     */
198    private static $feedback_fields = array();
199
200    /**
201     * Does the response have files attached to it?
202     *
203     * @var bool
204     */
205    protected $has_file = false;
206
207    /**
208     * The status of the feedback entry.
209     *
210     * @var string
211     */
212    protected $status = 'publish'; // Default status is 'publish' or other statuses as needed.
213
214    /**
215     * The IP address of the user who submitted the feedback.
216     *
217     * This is only available on form submissions, and might not be available when retrieving existing feedback posts in case the site admin decides to not store the IP address.
218     *
219     * @var string|null
220     */
221    protected $ip_address = null;
222
223    /**
224     * The user agent of the user who submitted the feedback.
225     *
226     * This is only available on form submissions, and might not be available when retrieving existing feedback posts.
227     *
228     * @var string|null
229     */
230    protected $user_agent = null;
231
232    /**
233     * The country code derived from the IP address.
234     *
235     * This is derived from the IP address and stored for easier display.
236     *
237     * @var string|null
238     */
239    protected $country_code = null;
240
241    /**
242     * The subject of the feedback entry.
243     *
244     * @var string
245     */
246    protected $subject = '';
247
248    /**
249     * Feedback ID of the feedback entry.
250     *
251     * Marked as legacy because it is not used in the new feedback system.
252     *
253     * @var string
254     */
255    protected $legacy_feedback_id = '';
256
257    /**
258     * The title of the feedback entry.
259     *
260     * Marked as legacy because it is not used in the new feedback system.
261     *
262     * @var string
263     */
264    protected $legacy_feedback_title = '';
265
266    /**
267     * The time of the feedback entry.
268     *
269     * This is used to store the title of the feedback entry.
270     *
271     * @var string
272     */
273    protected $feedback_time = '';
274
275    /**
276     * The Feedback_Author of the feedback entry.
277     *
278     * @var Feedback_Author
279     */
280    protected $author_data;
281
282    /**
283     * The comment content of the feedback entry.
284     *
285     * @var string
286     */
287    protected $comment_content = '';
288
289    /**
290     * Whether the user has given consent for data processing.
291     *
292     * @var bool
293     */
294    protected $has_consent = false;
295
296    /**
297     * Whether the feedback entry is unread.
298     *
299     * @var bool
300     */
301    protected $is_unread = true;
302
303    /**
304     * The post ID of the feedback entry.
305     *
306     * @var int|null
307     */
308    protected $post_id = null;
309
310    /**
311     * The entry object of the post that the feedback was submitted from.
312     *
313     * This is used to store the entry object of the post that the feedback was submitted from.
314     *
315     * @var Feedback_Source
316     */
317    protected $source;
318
319    /**
320     * The notification recipients of the feedback entry.
321     *
322     * @var array
323     */
324    protected $notification_recipients = array();
325
326    /**
327     * The jetpack_form post ID associated with this feedback, when available.
328     *
329     * @var int|null
330     */
331    protected $form_id = null;
332
333    /**
334     * The logged-in user who submitted the feedback, if any.
335     *
336     * @var array|null Array with 'display_name' and 'id' keys, or null if not logged in.
337     */
338    protected $logged_in_user = null;
339
340    /**
341     * Create a response object from a feedback post ID.
342     *
343     * @param int $feedback_post_id The ID of the feedback post.
344     * @return static|null
345     */
346    public static function get( $feedback_post_id ) {
347        $feedback_post = get_post( $feedback_post_id );
348        if ( ! $feedback_post || self::POST_TYPE !== $feedback_post->post_type ) {
349            return null;
350        }
351
352        if ( isset( self::$feedback_fields[ $feedback_post->ID ] ) ) {
353            return self::$feedback_fields[ $feedback_post->ID ];
354        }
355
356        $instance = new self();
357        $instance->load_from_post( $feedback_post );
358        self::$feedback_fields[ $feedback_post->ID ] = $instance;
359        return $instance;
360    }
361
362    /**
363     * Clear the internal cache of feedback objects.
364     *
365     * Useful for testing or when feedback data needs to be reloaded fresh.
366     *
367     * @since 6.10.0
368     */
369    public static function clear_cache() {
370        self::$feedback_fields = array();
371    }
372
373    /**
374     * Create a Feedback object from a feedback post.
375     *
376     * @param WP_Post $feedback_post The feedback post object.
377     */
378    private function load_from_post( WP_Post $feedback_post ) {
379
380        $parsed_content = $this->parse_content( $feedback_post->post_content, $feedback_post->post_mime_type );
381
382        $this->post_id            = $feedback_post->ID;
383        $this->status             = $feedback_post->post_status;
384        $this->legacy_feedback_id = $feedback_post->post_name;
385        $this->feedback_time      = $feedback_post->post_date;
386        $this->is_unread          = $feedback_post->comment_status === self::STATUS_UNREAD;
387
388        $this->fields = $parsed_content['fields'] ?? array();
389
390        // Check if post_parent is a jetpack_form post
391        $potential_form_id = $feedback_post->post_parent;
392        if ( $potential_form_id > 0 ) {
393            $parent_post = get_post( $potential_form_id );
394            if ( $parent_post && $parent_post->post_type === 'jetpack_form' ) {
395                // New data: post_parent is form ID
396                $this->form_id = $potential_form_id;
397            }
398        }
399
400        // Determine the source ID for this feedback.
401        // Prefer the explicit source_id from parsed content when available,
402        // otherwise fall back to the legacy behavior where post_parent was
403        // used as the source post ID, but only when no explicit form_id exists.
404        $source_id = 0;
405        if ( isset( $parsed_content['source_id'] ) && null !== $parsed_content['source_id'] ) {
406            $source_id = (int) $parsed_content['source_id'];
407        } elseif ( $feedback_post->post_parent && ! $this->form_id ) {
408            $source_id = (int) $feedback_post->post_parent;
409        }
410
411        $this->source = new Feedback_Source(
412            $source_id,
413            $parsed_content['entry_title'] ?? '',
414            $parsed_content['entry_page'] ?? 1,
415            $parsed_content['source_type'] ?? 'single',
416            $parsed_content['request_url'] ?? ''
417        );
418
419        $this->ip_address   = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' );
420        $this->country_code = $parsed_content['country_code'] ?? null;
421        $this->user_agent   = $parsed_content['user_agent'] ?? null;
422        $this->subject      = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' );
423
424        $this->notification_recipients = $parsed_content['notification_recipients'] ?? array();
425        $this->logged_in_user          = $parsed_content['logged_in_user'] ?? null;
426
427        $this->author_data = new Feedback_Author(
428            $this->get_first_field_of_type( 'name', 'pre_comment_author_name' ),
429            $this->get_first_field_of_type( 'email', 'pre_comment_author_email' ),
430            $this->get_first_field_of_type( 'url', 'pre_comment_author_url' ),
431            $this->get_field_value_by_form_field_id( 'first-name' ),
432            $this->get_field_value_by_form_field_id( 'last-name' )
433        );
434
435        $this->comment_content = $this->get_first_field_of_type( 'textarea' );
436        $this->has_consent     = (bool) $this->get_first_field_of_type( 'consent' );
437
438        $this->legacy_feedback_title = $feedback_post->post_title ? $feedback_post->post_title : $this->get_author() . ' - ' . $feedback_post->post_date;
439    }
440
441    /**
442     * Create a response object from a form submission.
443     *
444     * @param array        $post_data Typically $_POST.
445     * @param Contact_Form $form      The form object.
446     * @param WP_Post|null $current_post The current post object, if available.
447     * @param int          $current_page_number The current page number associated with the current post object entry.
448     *
449     * @return static
450     */
451    public static function from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
452        $instance = new self();
453        $instance->load_from_submission( $post_data, $form, $current_post, $current_page_number );
454        return $instance;
455    }
456
457    /**
458     * Set the source of the feedback entry.
459     *
460     * @param Feedback_Source $source The source object.
461     */
462    public function set_source( $source ) {
463        $this->source = $source;
464    }
465
466    /**
467     * Load from Form Submission.
468     *
469     * @param array        $post_data The $_POST received during the form submission.
470     * @param Contact_Form $form  The form object.
471     * @param WP_Post|null $current_post The current post object, if available.
472     * @param int          $current_page_number The current page number associated with the current post object entry.
473     */
474    private function load_from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
475
476        $this->source = Feedback_Source::from_submission( $current_post, $current_page_number );
477
478        // Use the form's ref attribute as the authoritative form ID.
479        // The ref is set server-side (from the JWT or shortcode attributes) and cannot be tampered with.
480        $form_id_attribute = $form->get_attribute( 'ref' );
481        $form_id_attribute = is_numeric( $form_id_attribute ) ? absint( $form_id_attribute ) : 0;
482        $this->form_id     = $form_id_attribute > 0 ? $form_id_attribute : null;
483
484        // If post_data is provided, use it to populate fields.
485        $this->fields          = $this->get_computed_fields( $post_data, $form );
486        $this->ip_address      = Contact_Form_Plugin::get_ip_address();
487        $this->country_code    = $this->get_country_code_from_ip( $this->ip_address );
488        $this->user_agent      = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null;
489        $this->subject         = $this->get_computed_subject( $post_data, $form );
490        $this->author_data     = Feedback_Author::from_submission( $post_data, $form );
491        $this->comment_content = $this->get_computed_comment_content( $post_data, $form );
492        $this->has_consent     = $this->get_computed_consent( $post_data, $form );
493
494        $this->notification_recipients = $this->get_computed_notification_recipients( $post_data, $form );
495
496        $this->feedback_time         = current_time( 'mysql' );
497        $this->legacy_feedback_title = "{$this->get_author()} - {$this->feedback_time}";
498        $this->legacy_feedback_id    = md5( $this->legacy_feedback_title );
499
500        // Capture logged-in user info at submission time.
501        if ( is_user_logged_in() ) {
502            $current_user         = wp_get_current_user();
503            $this->logged_in_user = array(
504                'display_name' => $current_user->display_name,
505                'username'     => $current_user->user_login,
506                'id'           => $current_user->ID,
507            );
508        }
509    }
510
511    /**
512     * Get a sanitized value from the post data.
513     *
514     * @param string      $key The key to look for in the post data.
515     * @param array       $post_data The post data array, typically $_POST.
516     * @param string|null $type The type of the field, if applicable (e.g., 'file').
517     *
518     * @return string|array The sanitized value, or an empty string if the key is not found.
519     */
520    private function get_field_value( $key, $post_data, $type = null ) {
521        if ( $type === 'file' ) {
522            if ( isset( $post_data[ $key ] ) ) {
523                return self::process_file_field_value( $post_data[ $key ] );
524            }
525            return array( 'files' => array() );
526        }
527
528        if ( $type === 'image-select' ) {
529            if ( isset( $post_data[ $key ] ) ) {
530                return self::process_image_select_field_value( $post_data[ $key ] );
531            }
532
533            return array(
534                'type'    => 'image-select',
535                'choices' => array(),
536            );
537        }
538
539        if ( isset( $post_data[ $key ] ) ) {
540            if ( is_array( $post_data[ $key ] ) ) {
541                return array_map( 'sanitize_textarea_field', wp_unslash( $post_data[ $key ] ) );
542            } else {
543                return sanitize_textarea_field( wp_unslash( $post_data[ $key ] ) );
544            }
545        }
546        return '';
547    }
548
549    /**
550     * Process the file field value.
551     *
552     * @param array $raw_data The raw post data from the file field.
553     *
554     * @return array The processed file data.
555     */
556    public static function process_file_field_value( $raw_data ) {
557        $file_data_array = is_array( $raw_data )
558            ? array_map(
559                function ( $json_str ) {
560                    $decoded = json_decode( stripslashes( $json_str ), true );
561                    return array(
562                        'file_id' => isset( $decoded['file_id'] ) ? sanitize_text_field( $decoded['file_id'] ) : '',
563                        'name'    => isset( $decoded['name'] ) ? sanitize_text_field( $decoded['name'] ) : '',
564                        'size'    => isset( $decoded['size'] ) ? absint( $decoded['size'] ) : 0,
565                        'type'    => isset( $decoded['type'] ) ? sanitize_text_field( $decoded['type'] ) : '',
566                    );
567                },
568                $raw_data
569            ) : array();
570
571        if ( empty( $file_data_array ) ) {
572            return array(
573                'files' => array(),
574            );
575        }
576
577        return array(
578            'files' => $file_data_array,
579        );
580    }
581
582    /**
583     * Process the image select field value.
584     *
585     * @param array $raw_data The raw post data from the image select field.
586     *
587     * @return array The processed image select data.
588     */
589    public static function process_image_select_field_value( $raw_data ) {
590        $value = array(
591            'type'    => 'image-select',
592            'choices' => array(),
593        );
594
595        $selection_data_array = is_array( $raw_data )
596            ? array_map(
597                function ( $json_str ) {
598                    return json_decode( stripslashes( $json_str ), true );
599                },
600                $raw_data
601            ) : array( json_decode( stripslashes( $raw_data ), true ) );
602
603        if ( ! empty( $selection_data_array ) ) {
604            $value['choices'] = $selection_data_array;
605        }
606
607        return $value;
608    }
609
610    /**
611     * Process a radio field value to detect and extract "Other" option metadata.
612     *
613     * This method checks if a radio field value matches the "Other" pattern and combines
614     * it with the corresponding text input value if present.
615     *
616     * @param string $value     The raw field value from the submission.
617     * @param object $field     The field object from the form.
618     * @param string $field_id  The field ID.
619     * @param array  $post_data The POST data from the submission.
620     *
621     * @return array An array with 'value' and 'meta' keys.
622     */
623    private function process_radio_field_value( $value, $field, $field_id, $post_data ) {
624        $meta        = array();
625        $allow_other = $field->get_attribute( 'allowother' );
626
627        if ( ! $allow_other || ! is_string( $value ) ) {
628            return array(
629                'value' => $value,
630                'meta'  => $meta,
631            );
632        }
633
634        $options_data = $field->get_attribute( 'optionsdata' );
635        $other_label  = null;
636
637        if ( ! empty( $options_data ) && is_array( $options_data ) ) {
638            foreach ( $options_data as $option ) {
639                if ( ! empty( $option['isOther'] ) ) {
640                    $other_label = Contact_Form_Plugin::strip_tags( $option['label'] );
641                    break;
642                }
643            }
644        }
645
646        if ( empty( $other_label ) ) {
647            return array(
648                'value' => $value,
649                'meta'  => $meta,
650            );
651        }
652
653        if ( $value === $other_label ) {
654            $other_text_key = $field_id . '-other-text';
655            $custom_text    = '';
656
657            if ( isset( $post_data[ $other_text_key ] ) ) {
658                $custom_text = sanitize_textarea_field( wp_unslash( $post_data[ $other_text_key ] ) );
659            }
660
661            $meta['is_other_option']  = true;
662            $meta['other_label']      = $other_label;
663            $meta['other_user_value'] = $custom_text;
664
665            if ( ! empty( $custom_text ) ) {
666                $value = $other_label . ': ' . $custom_text;
667            }
668        }
669
670        return array(
671            'value' => $value,
672            'meta'  => $meta,
673        );
674    }
675
676    /**
677     * Get the computed fields from the post data.
678     *
679     * @param string $label The label of the field to look for.
680     * @param string $context The context in which the value is being rendered (default is 'default').
681     *
682     * @return string The Value of the field.
683     */
684    public function get_field_value_by_label( $label, $context = 'default' ) {
685        // This method is used to get the value of a field by its label.
686        foreach ( $this->fields as $field ) {
687            if ( $field->get_label( $context ) === $label ) {
688                return $field->get_render_value( $context );
689            }
690        }
691        return '';
692    }
693
694    /**
695     * Get the value of the field based on the first type found.
696     *
697     * @param string      $type The type of the field to look for.
698     * @param string|null $filter Optional filter to apply to the value.
699     * @param string      $context The context in which the value is being rendered (default is 'default').
700     *
701     * @return string The value of the first field of the specified type, or an empty string if not found.
702     */
703    private function get_first_field_of_type( $type, $filter = null, $context = 'default' ) {
704        // This method is used to get the first field of a specific type.
705        foreach ( $this->fields as $field ) {
706            if ( $field->get_type() === $type ) {
707                if ( $filter ) {
708                    return Contact_Form_Plugin::strip_tags(
709                        stripslashes(
710                            /** This filter is already documented in core/wp-includes/comment-functions.php */
711                            \apply_filters( $filter, addslashes( $field->get_render_value( $context ) ) )
712                        )
713                    );
714                }
715                return $field->get_render_value( $context );
716            }
717        }
718        return '';
719    }
720
721    /**
722     * Get all the fields of the response.
723     */
724    public function get_fields() {
725        return $this->fields;
726    }
727
728    /**
729     * Check whether this feedback contains at least one field of a given type.
730     *
731     * @param string $type Field type to check for (e.g. 'consent', 'email', 'textarea').
732     * @return bool True if a field of the given type exists; false otherwise.
733     */
734    public function has_field_type( $type ) {
735        foreach ( $this->fields as $field ) {
736            if ( $field->get_type() === $type ) {
737                return true;
738            }
739        }
740        return false;
741    }
742
743    /**
744     * Get the values related to where the form was submitted from.
745     *
746     * @return array An array of entry values.
747     */
748    public function get_entry_values() {
749        // This is a convenience method to get the entry values in a simple array format.
750        $entry_values = array(
751            'email_marketing_consent' => (string) $this->has_consent ? 'yes' : 'no',
752            'entry_title'             => $this->source->get_title(),
753            'entry_permalink'         => $this->source->get_permalink(),
754            'feedback_id'             => $this->legacy_feedback_id,
755        );
756
757        if ( $this->source->get_page_number() > 1 ) {
758            $entry_values['entry_page'] = $this->source->get_page_number();
759        }
760        return $entry_values;
761    }
762
763    /**
764     * Get all values of the response.
765     *
766     * @param string $context The context in which the values are being retrieved.
767     *
768     * @return array An array of all values, including fields and entry values.
769     */
770    public function get_all_values( $context = 'default' ) {
771        // This is a legacy method to maintain compatibility with older code.
772        return array_merge( $this->get_compiled_fields( $context, 'key-value' ), $this->get_entry_values() );
773    }
774
775    /**
776     * Get the jetpack_form post ID associated with this feedback.
777     *
778     * @return int|null The form ID, or null if not submitted via reusable form.
779     */
780    public function get_form_id() {
781        return $this->form_id;
782    }
783
784    /**
785     * Get extra values.
786     * This is a legacy method to maintain compatibility with older code.
787     *
788     * @param string $context The context in which the values are being retrieved.
789     *
790     * @return array An array of extra values, including entry values
791     */
792    public function get_legacy_extra_values( $context = 'default' ) {
793        $count            = 1;
794        $_extra_fields    = array();
795        $special_fields   = array();
796        $non_extra_fields = array( 'email', 'name', 'url', 'subject', 'textarea', 'ip' );
797
798        // Create a map of special fields to check against their values.
799        foreach ( $this->fields as $field ) {
800            if ( in_array( $field->get_type(), $non_extra_fields, true ) ) {
801                $value = $field->get_render_value( $context );
802                if ( is_array( $value ) ) {
803                    $value = reset( $value );
804                }
805                if ( $value ) {
806                    $special_fields[ $value ] = true;
807                }
808            }
809        }
810
811        foreach ( $this->fields as $field ) {
812            if ( $field->compile_field( 'default' ) ) {
813                continue;
814            }
815            $render_value = $field->get_render_value();
816            if ( is_array( $render_value ) ) {
817                $render_value = reset( $render_value );
818            }
819            if ( $field->get_type() === 'basic' && $render_value && isset( $special_fields[ $render_value ] ) ) {
820                ++$count;
821                continue; // Skip fields that are already present in the non-extra fields.
822            }
823            $_extra_fields[] = $field;
824            ++$count; // Increment count to ensure unique keys for extra values.
825        }
826        $extra_values       = array();
827        $extra_fields_count = $count;
828        $is_present         = array(); // Used to store the value only once.
829
830        foreach ( $_extra_fields as $field ) {
831            if ( ! in_array( $field->get_type(), $non_extra_fields, true ) || isset( $is_present[ $field->get_type() ] ) ) {
832                $extra_values[ $extra_fields_count . '_' . $field->get_label() ] = $field->get_render_value( $context );
833                ++$extra_fields_count; // Increment count to ensure unique keys for extra values.
834            } else {
835                $is_present[ $field->get_type() ] = true;
836            }
837        }
838        return $extra_values;
839    }
840
841    /**
842     * Get all values of the response.
843     *
844     * @return array An array of all values, including fields and entry values.
845     */
846    public function get_all_legacy_values() {
847        return array(
848            '_feedback_author'       => $this->get_author(),
849            '_feedback_author_email' => $this->get_author_email(),
850            '_feedback_author_url'   => $this->get_author_url(),
851            '_feedback_subject'      => $this->get_subject(),
852            '_feedback_ip'           => $this->get_ip_address(),
853            '_feedback_all_fields'   => $this->get_all_values(),
854        );
855    }
856    /**
857     * Return the compiled fields for the given context.
858     *
859     * @param string $context The context in which the fields are compiled.
860     * @param string $array_shape The shape of the array to return. Can be 'all', 'value', 'label', or 'key-value'.
861     *
862     * @return array An array of compiled fields with labels and values.
863     */
864    public function get_compiled_fields( $context = 'default', $array_shape = 'all' ) {
865        $compiled_fields = array();
866
867        $count_field_labels = array();
868        foreach ( $this->fields as $field ) {
869            if ( $field->compile_field( $context ) ) {
870                continue; // Skip fields that are not meant to be rendered.
871            }
872
873            // Don't show the hidden fields in the user context.
874            if ( in_array( $context, array( 'web', 'ajax' ), true ) ) {
875                if ( $field->is_of_type( 'hidden' ) ) {
876                    continue;
877                }
878            }
879
880            $label = $field->get_label( $context );
881
882            if ( ! isset( $count_field_labels[ $label ] ) ) {
883                $count_field_labels[ $label ] = 1;
884            } else {
885                ++$count_field_labels[ $label ];
886            }
887
888            // Compile the field based on the requested shape.
889            switch ( $array_shape ) {
890                case 'default':
891                case 'all':
892                    $compiled_fields[ $field->get_key() ] = array(
893                        'label' => $label,
894                        'value' => $field->get_render_value( $context ),
895                    );
896                    break;
897                case 'label|value':
898                    $compiled_fields[] = array(
899                        'label' => $label,
900                        'value' => $field->get_render_value( $context ),
901                    );
902                    break;
903                case 'value':
904                    $compiled_fields[] = $field->get_render_value( $context );
905                    break;
906                case 'label':
907                    $compiled_fields[] = $label;
908                    break;
909                case 'key-value':
910                    $compiled_fields[ $field->get_key() ] = $field->get_render_value( $context );
911                    break;
912                case 'label-value':
913                    $compiled_fields[ $field->get_label( $context, $count_field_labels[ $label ] ) ] = $field->get_render_value( $context );
914                    break;
915                case 'id-value':
916                    $compiled_fields[ $field->get_form_field_id() ] = $field->get_render_value( $context );
917                    break;
918                case 'collection':
919                    $compiled_fields[] = array(
920                        'label' => $label,
921                        'value' => $field->get_render_value( $context ),
922                        'type'  => $field->get_type(),
923                        'id'    => $field->get_form_field_id(),
924                        'key'   => $field->get_key(),
925                        'meta'  => $field->get_meta(),
926                    );
927                    break;
928            }
929        }
930
931        return $compiled_fields;
932    }
933
934    /**
935     * Get the feedback ID of the response.
936     * Which is the same as the post name for feedback entries.
937     * Please note that this is not the same as the feedback post ID.
938     *
939     * @return string
940     */
941    public function get_feedback_id() {
942        return $this->legacy_feedback_id;
943    }
944
945    /**
946     * Get the feedback title of the response.
947     *
948     * This is mostly used for legacy reasons.
949     *
950     * @return string
951     */
952    public function get_title() {
953        return $this->legacy_feedback_title;
954    }
955
956    /**
957     * Get the time of the feedback entry.
958     *
959     * @return string
960     */
961    public function get_time() {
962        return $this->feedback_time;
963    }
964
965    /**
966     * Get the askimet vars that are used to check for spam.
967     *
968     * These are the variables that are sent to Akismet to check if the feedback is spam or not.
969     *
970     * @return array
971     */
972    public function get_akismet_vars() {
973        $akismet_vars = array(
974            'comment_author'       => $this->author_data->get_name(),
975            'comment_author_email' => $this->author_data->get_email(),
976            'comment_author_url'   => $this->author_data->get_url(),
977            'contact_form_subject' => $this->get_subject(),
978            'comment_author_ip'    => $this->get_ip_address(),
979            'comment_content'      => empty( $this->get_comment_content() ) ? null : $this->get_comment_content(),
980            'permalink'            => $this->get_entry_permalink(),
981        );
982
983        foreach ( $this->fields as $field ) {
984
985            // Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value
986            // from a spam-filtering point of view.
987            if ( in_array( $field->get_type(), array( 'select', 'checkbox', 'checkbox-multiple', 'radio', 'file', 'image-select' ), true ) ) {
988                continue;
989            }
990
991            // Normalize the label into a slug.
992            $field_slug = trim( // Strip all leading/trailing dashes.
993                preg_replace(   // Normalize everything to a-z0-9_-
994                    '/[^a-z0-9_]+/',
995                    '-',
996                    strtolower( $field->get_label() ) // Lowercase
997                ),
998                '-'
999            );
1000
1001            $field_value = $field->get_render_value( 'akismet' );
1002
1003            // Skip any values that are already in the array we're sending.
1004            if ( $field_value && in_array( $field_value, $akismet_vars, true ) ) {
1005                continue;
1006            }
1007
1008            $akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value;
1009        }
1010
1011        return $akismet_vars;
1012    }
1013
1014    /**
1015     * Get the author name of the feedback entry.
1016     * If the author is not provided we will use the email instead.
1017     *
1018     * @return string
1019     */
1020    public function get_author() {
1021        return $this->author_data->get_display_name();
1022    }
1023
1024    /**
1025     * Get the author name of a feedback entry.
1026     *
1027     * @return string
1028     */
1029    public function get_author_name() {
1030        return $this->author_data->get_name();
1031    }
1032
1033    /**
1034     * Get the author's first name of a feedback entry.
1035     *
1036     * @return string
1037     */
1038    public function get_author_first_name() {
1039        return $this->author_data->get_first_name();
1040    }
1041
1042    /**
1043     * Get the author's last name of a feedback entry.
1044     *
1045     * @return string
1046     */
1047    public function get_author_last_name() {
1048        return $this->author_data->get_last_name();
1049    }
1050
1051    /**
1052     * Get the author email of a feedback entry.
1053     *
1054     * @return string
1055     */
1056    public function get_author_email() {
1057        return $this->author_data->get_email();
1058    }
1059
1060    /**
1061     * Get the author's gravatar URL.
1062     *
1063     * This is a convenience method to get the author's gravatar URL.
1064     *
1065     * @return string
1066     */
1067    public function get_author_avatar() {
1068        return $this->author_data->get_avatar_url();
1069    }
1070
1071    /**
1072     * Get the author url of a feedback entry.
1073     *
1074     * @return string
1075     */
1076    public function get_author_url() {
1077        return $this->author_data->get_url();
1078    }
1079
1080    /**
1081     * Get the comment content of a feedback entry.
1082     *
1083     * @return string
1084     */
1085    public function get_comment_content() {
1086        return $this->comment_content;
1087    }
1088
1089    /**
1090     * Get the IP address of the submitted feedback request.
1091     *
1092     * @return string|null
1093     */
1094    public function get_ip_address() {
1095        return $this->ip_address;
1096    }
1097
1098    /**
1099     * Get the user agent of the submitted feedback request.
1100     *
1101     * @return string|null
1102     */
1103    public function get_user_agent() {
1104        return $this->user_agent;
1105    }
1106
1107    /**
1108     * Get the country code derived from the IP address.
1109     *
1110     * @return string|null
1111     */
1112    public function get_country_code() {
1113        return $this->country_code;
1114    }
1115
1116    /**
1117     * Get the emoji flag for the country.
1118     *
1119     * @return string The emoji flag for the country code, or empty string if unavailable.
1120     */
1121    public function get_country_flag() {
1122        return self::country_code_to_emoji_flag( $this->country_code );
1123    }
1124
1125    /**
1126     * Get country code from IP address.
1127     *
1128     * This method uses a filter to allow custom implementations of GeoIP lookup.
1129     * The filter should return a country code (e.g., 'US', 'GB', 'DE') or null.
1130     *
1131     * @param string|null $ip_address The IP address.
1132     * @return string|null The country code or null if unavailable.
1133     */
1134    private function get_country_code_from_ip( $ip_address ) {
1135        if ( ! $ip_address ) {
1136            return null;
1137        }
1138        // This filter allows site owners to disable IP address storage entirely as well as GeoIP lookups.
1139        // This filter is documented in src/contact-form/class-contact-form-plugin.php
1140        if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false ) ) {
1141            return null;
1142        }
1143
1144        /**
1145         * Filter to get country code from IP address.
1146         *
1147         * @since $$NEXT_VERSION$$
1148         *
1149         * @param string|null $country The country code (e.g., 'US', 'GB', 'DE') or null.
1150         * @param string      $ip_address The IP address to look up.
1151         * @param string      $context The context for the geolocation request.
1152         */
1153        $country = apply_filters( 'jetpack_get_country_from_ip', null, $ip_address, 'form-response' );
1154        if ( is_string( $country ) ) {
1155            return strtoupper( $country );
1156        }
1157
1158        $headers = array(
1159            'MM_COUNTRY_CODE',
1160            'GEOIP_COUNTRY_CODE',
1161            'HTTP_CF_IPCOUNTRY',
1162            'HTTP_X_COUNTRY_CODE',
1163            'HTTP_X_APPENGINE_COUNTRY',
1164            'HTTP_X_FORWARDED_FOR_COUNTRY',
1165            'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
1166        );
1167
1168        // Check for headers from the server.
1169        foreach ( $headers as $header ) {
1170            if ( isset( $_SERVER[ $header ] ) ) {
1171                $country = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
1172                if ( ! empty( $country ) ) {
1173                    return strtoupper( $country );
1174                }
1175            }
1176        }
1177
1178        if ( function_exists( 'geoip_country_code_by_name' ) ) {
1179            $country = geoip_country_code_by_name( $ip_address );
1180            if ( ! empty( $country ) ) {
1181                return strtoupper( $country );
1182            }
1183        }
1184
1185        $country = self::geolocate_via_api( $ip_address );
1186        if ( ! empty( $country ) ) {
1187            return strtoupper( $country );
1188        }
1189
1190        return null;
1191    }
1192
1193    /**
1194     * Use APIs to Geolocate the IP address.
1195     *
1196     * @param  string $ip_address IP address.
1197     * @return string
1198     */
1199    private static function geolocate_via_api( $ip_address ) {
1200        $country_code = \get_transient( 'geoip_' . $ip_address );
1201        if ( false === $country_code ) {
1202            $response = Client::wpcom_json_api_request_as_blog(
1203                '/ip-to-geo/' . $ip_address,
1204                '2',
1205                array( 'method' => 'GET' ),
1206                null,
1207                'wpcom'
1208            );
1209
1210            if ( ! is_wp_error( $response ) && ! empty( $response['body'] ) ) {
1211                $data         = json_decode( $response['body'] );
1212                $country_code = $data->country_short ?? '';
1213                $country_code = \sanitize_text_field( $country_code );
1214                // Share the transient with woocommerce to avoid multiple lookups.
1215                \set_transient( 'geoip_' . $ip_address, $country_code, DAY_IN_SECONDS );
1216            }
1217        }
1218        return $country_code;
1219    }
1220
1221    /**
1222     * Get the browser information from the user agent.
1223     *
1224     * Returns a formatted string like "Chrome (Desktop)" or "Safari (Mobile)".
1225     *
1226     * @return string|null Browser information or null if user agent is not available.
1227     */
1228    public function get_browser() {
1229        if ( empty( $this->user_agent ) ) {
1230            return null;
1231        }
1232
1233        // Use Jetpack Device Detection to parse the user agent.
1234        $ua_info = new User_Agent_Info( $this->user_agent );
1235
1236        // Get browser name.
1237        $browser_name = $ua_info->get_browser_display_name();
1238
1239        if ( $browser_name === User_Agent_Info::OTHER ) {
1240            return __( 'Unknown browser', 'jetpack-forms' );
1241        }
1242
1243        // Determine platform type (Mobile, Tablet, or Desktop).
1244        $platform_type = 'Desktop';
1245        if ( $ua_info->is_tablet() ) {
1246            $platform_type = 'Tablet';
1247        } elseif ( $ua_info->get_platform() ) {
1248            // If there's a mobile platform detected (not false), it's mobile.
1249            $platform_type = 'Mobile';
1250        }
1251
1252        return sprintf( '%s (%s)', $browser_name, $platform_type );
1253    }
1254
1255    /**
1256     * Get the logged-in user information who submitted the feedback.
1257     *
1258     * @return array|null Array with 'display_name' and 'id' keys, or null if not logged in.
1259     */
1260    public function get_logged_in_user() {
1261        return $this->logged_in_user;
1262    }
1263
1264    /**
1265     * Get the email subject.
1266     *
1267     * @return string
1268     */
1269    public function get_subject() {
1270        return $this->subject;
1271    }
1272
1273    /**
1274     * Gets the notification recipients of the feedback entry.
1275     *
1276     * @return array
1277     */
1278    public function get_notification_recipients() {
1279        return $this->notification_recipients;
1280    }
1281
1282    /**
1283     * Gets the value of the consent field.
1284     *
1285     * @return bool
1286     */
1287    public function has_consent() {
1288        return $this->has_consent;
1289    }
1290
1291    /**
1292     * Gets the value of the consent field.
1293     *
1294     * @return bool
1295     */
1296    public function has_file() {
1297        return $this->has_file;
1298    }
1299
1300    /**
1301     * Check if the feedback is unread.
1302     *
1303     * @return bool
1304     */
1305    public function is_unread() {
1306        return $this->is_unread;
1307    }
1308
1309    /**
1310     * Mark the feedback as read.
1311     *
1312     * @return bool True on success, false on failure.
1313     */
1314    public function mark_as_read() {
1315        if ( ! $this->post_id ) {
1316            return false;
1317        }
1318
1319        $updated = wp_update_post(
1320            array(
1321                'ID'             => $this->post_id,
1322                'comment_status' => self::STATUS_READ,
1323            )
1324        );
1325
1326        if ( ! is_wp_error( $updated ) && $updated ) {
1327            $this->is_unread = false;
1328            return true;
1329        }
1330
1331        return false;
1332    }
1333
1334    /**
1335     * Mark the feedback as unread.
1336     *
1337     * @return bool True on success, false on failure.
1338     */
1339    public function mark_as_unread() {
1340        if ( ! $this->post_id ) {
1341            return false;
1342        }
1343
1344        $updated = wp_update_post(
1345            array(
1346                'ID'             => $this->post_id,
1347                'comment_status' => self::STATUS_UNREAD,
1348            )
1349        );
1350
1351        if ( ! is_wp_error( $updated ) && $updated ) {
1352            $this->is_unread = true;
1353            return true;
1354        }
1355
1356        return false;
1357    }
1358
1359    /**
1360     * Get the count of unread feedback entries.
1361     *
1362     * @return int
1363     */
1364    public static function get_unread_count() {
1365        $query = new \WP_Query(
1366            array(
1367                'post_type'      => self::POST_TYPE,
1368                'post_status'    => 'publish',
1369                'comment_status' => self::STATUS_UNREAD,
1370                'posts_per_page' => -1,
1371                'fields'         => 'ids',
1372            )
1373        );
1374        return (int) $query->found_posts;
1375    }
1376
1377    /**
1378     * Get the uploaded files from the feedback entry.
1379     *
1380     * @return array
1381     */
1382    public function get_files() {
1383        $files = array();
1384        foreach ( $this->fields as $field ) {
1385            if ( $field->get_type() === 'file' ) {
1386                $field_value = $field->get_value();
1387                if ( ! empty( $field_value['files'] ) && is_array( $field_value['files'] ) ) {
1388                    $field_value['files'] = array_filter(
1389                        $field_value['files'],
1390                        function ( $file ) {
1391                            if ( empty( $file['file_id'] ) ) {
1392                                return false;
1393                            }
1394                            if ( empty( $file['name'] ) ) {
1395                                return false;
1396                            }
1397                            if ( empty( $file['size'] ) ) {
1398                                return false;
1399                            }
1400                            if ( empty( $file['type'] ) ) {
1401                                return false;
1402                            }
1403                            return true;
1404                        }
1405                    );
1406
1407                    $files = array_merge( $files, $field_value['files'] );
1408                }
1409            }
1410        }
1411        return $files;
1412    }
1413
1414    /**
1415     * Get the feedback status. For example 'publish', 'spam' or 'trash'.
1416     *
1417     * @return string
1418     */
1419    public function get_status() {
1420        return $this->status;
1421    }
1422
1423    /**
1424     * Sets the status of the feedback.
1425     *
1426     * @param string $status The status to set for the feedback entry.
1427     * @return void
1428     */
1429    public function set_status( $status ) {
1430        $this->status = $status;
1431    }
1432
1433    /**
1434     * Get the entry ID of the post that the feedback was submitted from.
1435     *
1436     * This is the post ID of the post or page that the feedback was submitted from.
1437     *
1438     * @return int|string
1439     */
1440    public function get_entry_id() {
1441        return $this->source->get_id();
1442    }
1443
1444    /**
1445     * Get the entry title of the post that the feedback was submitted from.
1446     *
1447     * This is the title of the post or page that the feedback was submitted from.
1448     *
1449     * @return string
1450     */
1451    public function get_entry_title() {
1452        return $this->source->get_title();
1453    }
1454
1455    /**
1456     * Get the permalink of the post or page that the feedback was submitted from.
1457     * This includes the page number if the feedback was submitted from a paginated form.
1458     *
1459     * @return string
1460     */
1461    public function get_entry_permalink() {
1462        return $this->source->get_permalink();
1463    }
1464
1465    /**
1466     * Get the editor URL where the user can edit the form.
1467     *
1468     * @return string
1469     */
1470    public function get_edit_form_url() {
1471        if ( ! empty( $this->form_id ) ) {
1472            return \get_edit_post_link( (int) $this->form_id, 'url' );
1473        }
1474        return $this->source->get_edit_form_url();
1475    }
1476    /**
1477     * Get the short permalink of a post.
1478     *
1479     * @return string
1480     */
1481    public function get_entry_short_permalink() {
1482        return $this->source->get_relative_permalink();
1483    }
1484    /**
1485     * Save the feedback entry to the database.
1486     *
1487     * @return int
1488     */
1489    public function save() {
1490        $post_id = wp_insert_post(
1491            array(
1492                'post_type'      => self::POST_TYPE,
1493                'post_status'    => $this->status,
1494                'post_title'     => $this->legacy_feedback_title,
1495                'post_date'      => $this->feedback_time,
1496                'post_name'      => $this->legacy_feedback_id,
1497                'post_content'   => $this->serialize(), // In V3 we started to addslashes.
1498                'post_mime_type' => 'v3', // a way to help us identify what version of the data this is.
1499                'post_parent'    => $this->form_id ?? $this->source->get_id(),
1500                'comment_status' => self::STATUS_UNREAD, // New feedback is unread by default.
1501            )
1502        );
1503
1504        // Store source post ID as meta for queryable source filtering.
1505        $source_id = $this->source->get_id();
1506        if ( is_numeric( $post_id ) && (int) $post_id > 0 && is_numeric( $source_id ) && (int) $source_id > 0 ) {
1507            add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
1508            wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
1509        }
1510
1511        // If this feedback does not have a jetpack_form parent,
1512        // it's a classic form â€” mark the state accordingly.
1513        if ( empty( $this->form_id ) ) {
1514            Forms_Dashboard::mark_classic_form_detected();
1515        }
1516
1517        $feedback_post = get_post( $post_id );
1518        return $feedback_post ?? 0;
1519    }
1520
1521    /**
1522     * Serialize the fields to JSON format.
1523     *
1524     * @return string
1525     */
1526    public function serialize() {
1527
1528        $fields_to_serialize = array_merge(
1529            array(
1530                'subject'                 => $this->subject,
1531                'ip'                      => $this->ip_address,
1532                'country_code'            => $this->country_code,
1533                'user_agent'              => $this->user_agent,
1534                'notification_recipients' => $this->notification_recipients,
1535                'logged_in_user'          => $this->logged_in_user,
1536            ),
1537            $this->source->serialize()
1538        );
1539
1540        $fields_to_serialize['fields'] = array();
1541        foreach ( $this->fields as $field ) {
1542            $fields_to_serialize['fields'][] = $field->serialize();
1543        }
1544
1545        // Check if the IP and country_code should be included.
1546        if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false, $this->ip_address ) ) {
1547            $fields_to_serialize['ip']           = null;
1548            $fields_to_serialize['country_code'] = null;
1549        }
1550
1551        return addslashes( wp_json_encode( $fields_to_serialize, JSON_UNESCAPED_SLASHES ) );
1552    }
1553
1554    /**
1555     * Helper function to parse the post content.
1556     *
1557     * @param string      $post_content The post content to parse.
1558     * @param string|null $version The version of the content format.
1559     * @return array Parsed fields.
1560     */
1561    private function parse_content( $post_content = '', $version = null ) {
1562        if ( $version === 'v3' ) {
1563            return $this->parse_content_v3( $post_content );
1564        }
1565        if ( $version === 'v2' ) {
1566            return $this->parse_content_v2( $post_content );
1567        }
1568        return $this->parse_legacy_content( $post_content );
1569    }
1570
1571    /**
1572     * Parse the content in the v2 format.
1573     *
1574     * V2 Format was a short lived format that accidently contains slash escaped unicode characters.
1575     *
1576     * @param string $post_content The post content to parse.
1577     *
1578     * @return array Parsed fields.
1579     */
1580    private function parse_content_v2( $post_content = '' ) {
1581        $decoded_content = json_decode( $post_content, true );
1582        if ( $decoded_content === null ) {
1583            // If JSON decoding still fails, try with stripslashes and trim as a fallback
1584            // This is a workaround for some cases where the JSON data is not properly formatted
1585            $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1586        }
1587
1588        if ( $decoded_content === null ) {
1589            // Final fallback: attempt to fix malformed JSON with unescaped quotes
1590            // Apply stripslashes first, then fix remaining issues
1591            $stripped_content = stripslashes( trim( $post_content ) );
1592            $fixed_content    = self::fix_malformed_json( $stripped_content );
1593            $decoded_content  = json_decode( $fixed_content, true );
1594        }
1595
1596        if ( $decoded_content === null ) {
1597            return array();
1598        }
1599        $fields = array();
1600        foreach ( $decoded_content['fields'] as $field ) {
1601            $feedback_field = Feedback_Field::from_serialized_v2( $field );
1602            if ( $feedback_field instanceof Feedback_Field ) {
1603                $fields[ $feedback_field->get_key() ] = $feedback_field;
1604                if ( ! $this->has_file && $feedback_field->has_file() ) {
1605                    $this->has_file = true;
1606                }
1607            }
1608        }
1609        $decoded_content['fields'] = $fields;
1610        return $decoded_content;
1611    }
1612
1613    /**
1614     * Parse the content in the v3 format.
1615     *
1616     * @param string $post_content The post content to parse.
1617     *
1618     * @return array Parsed fields.
1619     */
1620    private function parse_content_v3( $post_content = '' ) {
1621        $decoded_content = json_decode( $post_content, true );
1622        if ( $decoded_content === null ) {
1623            // If JSON decoding fails, try to decode the second try with stripslashes and trim.
1624            // This is a workaround for some cases where the JSON data is not properly formatted.
1625            $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1626        }
1627        if ( $decoded_content === null ) {
1628            return array();
1629        }
1630        $fields = array();
1631        foreach ( $decoded_content['fields'] as $field ) {
1632            $feedback_field = Feedback_Field::from_serialized( $field );
1633            if ( $feedback_field instanceof Feedback_Field ) {
1634                $fields[ $feedback_field->get_key() ] = $feedback_field;
1635                if ( ! $this->has_file && $feedback_field->has_file() ) {
1636                    $this->has_file = true;
1637                }
1638            }
1639        }
1640        $decoded_content['fields'] = $fields;
1641        return $decoded_content;
1642    }
1643
1644    /**
1645     * Parse the legacy content format.
1646     *
1647     * @param string $post_content The post content to parse.
1648     *
1649     * @return array Parsed fields.
1650     */
1651    private function parse_legacy_content( $post_content = '' ) {
1652        $content_parts   = $this->split_legacy_content( $post_content );
1653        $comment_content = $content_parts['comment_content'];
1654        $field_content   = $content_parts['field_content'];
1655
1656        $all_values = $this->extract_legacy_values( $field_content );
1657        $lines      = $this->extract_legacy_lines( $field_content );
1658
1659        $decoded_fields           = array();
1660        $decoded_fields['fields'] = array();
1661
1662        // Process lines for specific field types
1663        $this->process_legacy_lines( $lines, $decoded_fields );
1664
1665        // Process all other values
1666        $this->process_legacy_values( $all_values, $decoded_fields );
1667
1668        // Add comment content field
1669        $this->add_comment_content_field( $comment_content, $decoded_fields );
1670
1671        return $decoded_fields;
1672    }
1673
1674    /**
1675     * Attempt to fix malformed JSON by escaping unescaped quotes in string values.
1676     *
1677     * This method handles cases where JSON contains unescaped quotes within string values,
1678     * which causes json_decode to fail.
1679     *
1680     * @param string $json malformed JSON string.
1681     * @return string The JSON string with escaped quotes.
1682     */
1683    public static function fix_malformed_json( $json ) {
1684
1685        $find    = array();
1686        $replace = array();
1687
1688        // Start of JSON object
1689        $find[]    = '{\"';
1690        $replace[] = '{"';
1691
1692        // Key-value separator
1693        $find[]    = '\":\"';
1694        $replace[] = '":"';
1695
1696        $find[]    = '\\\"';
1697        $replace[] = '\"';
1698
1699        $find[]    = '\":[\"';
1700        $replace[] = '":["';
1701
1702        $find[]    = '\"],';
1703        $replace[] = '"],';
1704
1705        $find[]    = ',[\"';
1706        $replace[] = ',["';
1707
1708        $find[]    = '\",\"';
1709        $replace[] = '","';
1710
1711        $find[]    = ',\"';
1712        $replace[] = ',"';
1713
1714        $find[]    = '\", \"';
1715        $replace[] = '", "';
1716
1717        $find[]    = '\"],\"';
1718        $replace[] = '"],"';
1719
1720        $find[]    = '\"],"';
1721        $replace[] = '"],"';
1722
1723        $find[]    = '\":[]';
1724        $replace[] = '":[]';
1725
1726        $find[]    = '\"]}';
1727        $replace[] = '"]}';
1728
1729        $find[]    = '\":[';
1730        $replace[] = '":[';
1731
1732        $find[]    = '\":{';
1733        $replace[] = '":{';
1734
1735        $find[]    = '\":true';
1736        $replace[] = '":true';
1737
1738        $find[]    = '\":false';
1739        $replace[] = '":false';
1740
1741        $find[]    = '\":null';
1742        $replace[] = '":null';
1743
1744        for ( $i = 0; $i <= 9; $i++ ) {
1745            $find[]    = '\":' . $i;
1746            $replace[] = '":' . $i;
1747
1748            $find[]    = '\",' . $i;
1749            $replace[] = '",' . $i;
1750        }
1751
1752        $find[]    = '\",true';
1753        $replace[] = '",true';
1754
1755        $find[]    = '\",false';
1756        $replace[] = '",false';
1757
1758        $find[]    = '\",null';
1759        $replace[] = '",null';
1760
1761        $find[]    = "\'";
1762        $replace[] = "'";
1763
1764        // End of Json object
1765        $find[]    = '\"}';
1766        $replace[] = '"}';
1767
1768        // Remove any slashes that are there to start a new string.
1769        return str_replace( $find, $replace, addslashes( $json ) );
1770    }
1771
1772    /**
1773     * Split legacy content into comment and field sections.
1774     *
1775     * @param string $post_content The post content to parse.
1776     * @return array Array with 'comment_content' and 'field_content' keys.
1777     */
1778    private function split_legacy_content( $post_content ) {
1779        $content         = explode( '<!--more-->', $post_content );
1780        $comment_content = '';
1781        $field_content   = '';
1782
1783        if ( count( $content ) > 1 ) {
1784            $comment_content = $content[0];
1785            $field_content   = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1786        }
1787
1788        return array(
1789            'comment_content' => $comment_content,
1790            'field_content'   => $field_content,
1791        );
1792    }
1793
1794    /**
1795     * Extract values from legacy field content.
1796     *
1797     * @param string $field_content The field content to parse.
1798     * @return array Extracted values.
1799     */
1800    private function extract_legacy_values( $field_content ) {
1801        $all_values = array();
1802
1803        if ( str_contains( $field_content, 'JSON_DATA' ) ) {
1804            $all_values = $this->parse_json_data( $field_content );
1805        } else {
1806            $all_values = $this->parse_array_format( $field_content );
1807        }
1808
1809        // Ensure all_values is always an array
1810        if ( ! is_array( $all_values ) ) {
1811            $all_values = array();
1812        }
1813
1814        return $all_values;
1815    }
1816
1817    /**
1818     * Extract lines from legacy field content.
1819     *
1820     * @param string $field_content The field content to parse.
1821     * @return array Filtered lines.
1822     */
1823    private function extract_legacy_lines( $field_content ) {
1824        if ( str_contains( $field_content, 'JSON_DATA' ) ) {
1825            $chunks = explode( "\nJSON_DATA", $field_content );
1826            return array_filter( explode( "\n", $chunks[0] ) );
1827        } else {
1828            return array_filter( explode( "\n", $field_content ) );
1829        }
1830    }
1831
1832    /**
1833     * Parse JSON data from field content.
1834     *
1835     * @param string $field_content The field content containing JSON data.
1836     * @return array Parsed JSON data.
1837     */
1838    private function parse_json_data( $field_content ) {
1839        $chunks = explode( "\nJSON_DATA", $field_content );
1840
1841        if ( ! isset( $chunks[1] ) ) {
1842            // Try with 'JSON_DATA' without the newline as a fallback.
1843            $chunks = explode( 'JSON_DATA', $field_content );
1844            if ( ! isset( $chunks[1] ) ) {
1845                // If JSON_DATA is still not found, return an empty array.
1846                return array();
1847            }
1848        }
1849
1850        $json_data = $chunks[1];
1851
1852        $all_values = json_decode( $json_data, true );
1853
1854        if ( $all_values === null ) {
1855            // Fallback for improperly formatted JSON
1856            $all_values = json_decode( stripslashes( trim( $json_data ) ), true );
1857        }
1858
1859        return $all_values === null ? array() : $all_values;
1860    }
1861
1862    /**
1863     * Parse array format from field content.
1864     *
1865     * @param string $field_content The field content in array format.
1866     * @return array Parsed array data.
1867     */
1868    private function parse_array_format( $field_content ) {
1869        $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $field_content );
1870
1871        // Parse key-value pairs formatted as [Key] => Value
1872        preg_match_all( '/^\s*\[([^\]]+)\] =\&gt\; (.*)(?=^\s*(\[[^\]]+\] =\&gt\;)|\z)/msU', $fields_array, $matches );
1873
1874        if ( count( $matches ) > 1 ) {
1875            return array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
1876        }
1877
1878        return array();
1879    }
1880
1881    /**
1882     * Process legacy lines into field objects.
1883     *
1884     * We do this so that we can extract specific fields but we don't display the values in the UI.
1885     *
1886     * @param array $lines The lines to process.
1887     * @param array &$decoded_fields Reference to the decoded fields array.
1888     */
1889    private function process_legacy_lines( $lines, &$decoded_fields ) {
1890        $var_map = array(
1891            'AUTHOR'       => array(
1892                'type'  => 'name',
1893                'label' => 'Author',
1894            ),
1895            'AUTHOR EMAIL' => array(
1896                'type'  => 'email',
1897                'label' => 'Email',
1898            ),
1899            'AUTHOR URL'   => array(
1900                'type'  => 'url',
1901                'label' => 'Url',
1902            ),
1903            'SUBJECT'      => array(
1904                'type'  => 'subject',
1905                'label' => 'Subject',
1906            ),
1907            'IP'           => array(
1908                'type'  => 'ip',
1909                'label' => 'IP',
1910            ),
1911        );
1912
1913        foreach ( $lines as $line ) {
1914            $line_parts = explode( ': ', $line, 2 );
1915
1916            if ( count( $line_parts ) !== 2 ) {
1917                continue;
1918            }
1919
1920            list( $key, $value ) = $line_parts;
1921
1922            if ( ! empty( $key ) && isset( $var_map[ $key ] ) ) {
1923                $map_to_field = $var_map[ $key ];
1924                $value        = Contact_Form_Plugin::strip_tags( trim( $value ) );
1925
1926                $decoded_fields['fields'][ $key ] = new Feedback_Field(
1927                    $key,
1928                    $map_to_field['label'],
1929                    $value,
1930                    $map_to_field['type'],
1931                    array( 'render' => false )
1932                );
1933            }
1934        }
1935    }
1936
1937    /**
1938     * Check if the field is a legacy file upload.
1939     *
1940     * @param array $field The field to check.
1941     *
1942     * @return bool True if it's a legacy file upload, false otherwise.
1943     */
1944    private function is_legacy_file_upload( $field ) {
1945        return (
1946            is_array( $field ) &&
1947            ! empty( $field['field_id'] ) &&
1948            isset( $field['files'] ) &&
1949            is_array( $field['files'] )
1950        );
1951    }
1952
1953    /**
1954     * Process legacy values into field objects.
1955     *
1956     * @param array $all_values The values to process.
1957     * @param array &$decoded_fields Reference to the decoded fields array.
1958     */
1959    private function process_legacy_values( $all_values, &$decoded_fields ) {
1960        $non_user_fields = array(
1961            'email_marketing_consent',
1962            'entry_title',
1963            'entry_permalink',
1964            'entry_page',
1965            'feedback_id',
1966        );
1967
1968        foreach ( $all_values as $key => $value ) {
1969            $key   = wp_strip_all_tags( $key );
1970            $label = self::extract_label_from_key( $key );
1971
1972            if ( in_array( $key, $non_user_fields, true ) ) {
1973                if ( $key === 'email_marketing_consent' ) {
1974                    $decoded_fields['fields'][ $key ] = new Feedback_Field(
1975                        $key,
1976                        $label,
1977                        $value,
1978                        'consent',
1979                        array( 'render' => false )
1980                    );
1981                    continue;
1982                }
1983                $decoded_fields[ $key ] = $value;
1984                continue;
1985            }
1986
1987            // check for file upload data and then set it as a file type field.
1988            if ( $this->is_legacy_file_upload( $value ) ) {
1989                // If the value is a file upload, we need to handle it differently.
1990                $decoded_fields['fields'][ $key ] = new Feedback_Field(
1991                    $key,
1992                    $label,
1993                    $value,
1994                    'file'
1995                );
1996                $this->has_file                   = ! empty( $value['files'] ); // Set has_file to true if any file upload is found.
1997            } else {
1998                $decoded_fields['fields'][ $key ] = new Feedback_Field( $key, $label, $value );
1999            }
2000        }
2001    }
2002
2003    /**
2004     * Add comment content as a field.
2005     *
2006     * @param string $comment_content The comment content.
2007     * @param array  &$decoded_fields Reference to the decoded fields array.
2008     */
2009    private function add_comment_content_field( $comment_content, &$decoded_fields ) {
2010        $decoded_fields['fields']['comment_content'] = new Feedback_Field(
2011            'comment_content',
2012            'Comment Content',
2013            trim( Contact_Form_Plugin::strip_tags( $comment_content ) ),
2014            'textarea',
2015            array( 'render' => false )
2016        );
2017    }
2018
2019    /**
2020     * Extract the label from a key that might be in the format "1_label".
2021     *
2022     * @param string $key The key to extract the label from.
2023     * @return string The extracted label.
2024     */
2025    private static function extract_label_from_key( $key ) {
2026        // Check if the key starts with a number followed by underscore and has content after underscore
2027        if ( preg_match( '/^\d+_(.+)$/', $key, $matches ) ) {
2028            return $matches[1];
2029        }
2030        // If the key is just a number followed by underscore (like "2_"), return empty string
2031        if ( preg_match( '/^\d+_$/', $key ) ) {
2032            return '';
2033        }
2034        // If the key doesn't start with a number followed by underscore, return the key as is
2035        return $key;
2036    }
2037
2038    /**
2039     * Get field-specific metadata based on the field type.
2040     *
2041     * @param Contact_Form_Field $field The field object.
2042     * @param string             $type  The field type.
2043     * @return array Metadata array for the field.
2044     */
2045    public static function get_field_meta( $field, $type ) {
2046        $meta = array();
2047
2048        if ( $type === 'rating' ) {
2049            $icon_style        = $field->get_attribute( 'iconstyle' );
2050            $max               = $field->get_attribute( 'max' );
2051            $meta['iconStyle'] = ! empty( $icon_style ) ? $icon_style : 'stars';
2052            $meta['maxRating'] = is_numeric( $max ) && (int) $max > 0 ? (int) $max : 5;
2053        }
2054
2055        return $meta;
2056    }
2057
2058    /**
2059     * Get all the fields of the response, computed from the post data.
2060     *
2061     * @param array        $post_data The post data from the form submission.
2062     * @param Contact_Form $form The form object.
2063     * @return array An array of Feedback_Field objects.
2064     */
2065    private function get_computed_fields( $post_data, $form ) {
2066
2067        $fields = array();
2068
2069        $field_ids = $form->get_field_ids();
2070        // For all fields, grab label and value
2071        $i = 1;
2072        foreach ( $field_ids['all'] as $field_id ) {
2073            $field = $form->fields[ $field_id ];
2074            $type  = $field->get_attribute( 'type' );
2075            if ( ! $field->is_field_renderable( $type ) ) {
2076                continue;
2077            }
2078
2079            $value = $this->get_field_value( $field_id, $post_data, $type );
2080            $label = wp_strip_all_tags( $field->get_attribute( 'label' ) );
2081            $key   = $i . '_' . $label;
2082
2083            $meta = self::get_field_meta( $field, $type );
2084
2085            // Process radio fields to detect and extract "Other" option metadata
2086            if ( $type === 'radio' ) {
2087                $processed = $this->process_radio_field_value( $value, $field, $field_id, $post_data );
2088                $value     = $processed['value'];
2089                $meta      = array_merge( $meta, $processed['meta'] );
2090            }
2091            $fields[ $key ] = new Feedback_Field( $key, $label, $value, $type, $meta, $field_id );
2092            if ( ! $this->has_file && $fields[ $key ]->has_file() ) {
2093                $this->has_file = true;
2094            }
2095            ++$i; // Increment prefix counter for the next field.
2096        }
2097
2098        return $fields;
2099    }
2100
2101    /**
2102     * Gets the computed subject.
2103     *
2104     * @param array        $post_data The post data from the form submission.
2105     * @param Contact_Form $form The form object.
2106     * @return string
2107     */
2108    private function get_computed_subject( $post_data, $form ) {
2109
2110        $contact_form_subject = $form->get_attribute( 'subject' );
2111        $field_ids            = $form->get_field_ids();
2112
2113        if ( isset( $field_ids['subject'] ) ) {
2114            $value = $this->get_field_value( $field_ids['subject'], $post_data );
2115            if ( ! empty( $value ) ) {
2116                $contact_form_subject = $value;
2117            }
2118        }
2119
2120        return apply_filters( 'contact_form_subject', $contact_form_subject, $this->get_all_values() );
2121    }
2122
2123    /**
2124     * Gets the computed comment content.
2125     *
2126     * @param array        $post_data The post data from the form submission.
2127     * @param Contact_Form $form The form object.
2128     * @return string
2129     */
2130    private function get_computed_comment_content( $post_data, $form ) {
2131        $field_ids = $form->get_field_ids();
2132        if ( isset( $field_ids['textarea'] ) ) {
2133            $value = $this->get_field_value( $field_ids['textarea'], $post_data );
2134            if ( is_string( $value ) ) {
2135                return trim( Contact_Form_Plugin::strip_tags( stripslashes( $value ) ) );
2136            }
2137        }
2138        return '';
2139    }
2140
2141    /**
2142     * Gets the computed consent.
2143     *
2144     * @param array        $post_data The post data from the form submission.
2145     * @param Contact_Form $form The form object.
2146     * @return bool
2147     */
2148    private function get_computed_consent( $post_data, $form ) {
2149        $field_ids = $form->get_field_ids();
2150
2151        if ( isset( $field_ids['email_marketing_consent_field'] ) && $field_ids['email_marketing_consent_field'] !== null ) {
2152            return (bool) $this->get_field_value( $field_ids['email_marketing_consent_field'], $post_data );
2153        }
2154
2155        return false;
2156    }
2157
2158    /**
2159     * Gets the computed notification recipients.
2160     *
2161     * @since 6.10.0
2162     *
2163     * @param array        $post_data The post data from the form submission.
2164     * @param Contact_Form $form The form object.
2165     * @return array
2166     */
2167    private function get_computed_notification_recipients( $post_data, $form ) {
2168        $notification_recipients = $form->get_attribute( 'notificationRecipients' );
2169        return $this->validate_notification_recipients( $notification_recipients );
2170    }
2171
2172    /**
2173     * Validates notification recipients have proper capabilities.
2174     *
2175     * Ensures each user ID corresponds to a real user with edit_posts or edit_pages capability.
2176     * Filters out invalid or unauthorized user IDs.
2177     *
2178     * @since 6.10.0
2179     *
2180     * @param array $recipients Array of user IDs.
2181     * @return array Array of validated user IDs.
2182     */
2183    private function validate_notification_recipients( $recipients ) {
2184        if ( ! is_array( $recipients ) ) {
2185            return array();
2186        }
2187
2188        $valid_recipients = array();
2189        foreach ( $recipients as $user_id ) {
2190            $user = get_userdata( $user_id );
2191            // Only allow users with edit_posts or edit_pages capability
2192            if ( $user && ( $user->has_cap( 'edit_posts' ) || $user->has_cap( 'edit_pages' ) ) ) {
2193                $valid_recipients[] = $user_id;
2194            }
2195        }
2196
2197        return $valid_recipients;
2198    }
2199
2200    /**
2201     * Get a field by its original form ID.
2202     *
2203     * @since 5.5.0
2204     *
2205     * @param string $id Original form field ID.
2206     * @return Feedback_Field|null
2207     */
2208    public function get_field_by_form_field_id( $id ) {
2209        if ( ! is_string( $id ) || $id === '' ) {
2210            return null;
2211        }
2212        foreach ( $this->fields as $field ) {
2213            if ( $field->get_form_field_id() === $id ) {
2214                return $field;
2215            }
2216        }
2217        return null;
2218    }
2219
2220    /**
2221     * Get a field render value by its original form ID.
2222     *
2223     * @since 5.5.0
2224     *
2225     * @param string $id Original form field ID.
2226     * @param string $context Render context.
2227     * @return string
2228     */
2229    public function get_field_value_by_form_field_id( $id, $context = 'default' ) {
2230        $field = $this->get_field_by_form_field_id( $id );
2231        if ( ! $field ) {
2232            return '';
2233        }
2234        return (string) $field->get_render_value( $context );
2235    }
2236}