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