Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.56% covered (success)
94.56%
799 / 845
76.40% covered (warning)
76.40%
68 / 89
CRAP
0.00% covered (danger)
0.00%
0 / 1
Feedback
94.56% covered (success)
94.56%
799 / 845
76.40% covered (warning)
76.40%
68 / 89
303.57
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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 is_json
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 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
1605        // Some feedback posts store JSON content without a version marker
1606        // (empty post_mime_type). Parse those as the current (v3) format instead
1607        // of falling through to the legacy plain-text parser.
1608        if ( self::is_json( $post_content ) ) {
1609            $decoded_content = json_decode( $post_content, true );
1610            if ( $decoded_content === null ) {
1611                // Content may be slash-escaped as stored by WordPress; retry,
1612                // mirroring the fallback used by the v2/v3 parsers.
1613                $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1614            }
1615            if ( isset( $decoded_content['fields'] ) && is_array( $decoded_content['fields'] ) ) {
1616                return $this->parse_content_v3( $post_content );
1617            }
1618        }
1619
1620        return $this->parse_legacy_content( $post_content );
1621    }
1622
1623    /**
1624     * Check whether a string looks like and decodes as valid JSON.
1625     *
1626     * Accepts slash-escaped JSON (as WordPress may store it), mirroring the
1627     * stripslashes fallback used by the v2/v3 parsers.
1628     *
1629     * @param string $string The string to test.
1630     * @return bool True if the string is a JSON object or array.
1631     */
1632    private static function is_json( $string ) {
1633        if ( ! is_string( $string ) || $string === '' ) {
1634            return false;
1635        }
1636        $string = trim( $string );
1637        if ( ! str_starts_with( $string, '{' ) && ! str_starts_with( $string, '[' ) ) {
1638            return false;
1639        }
1640        $decoded = json_decode( $string );
1641        if ( $decoded !== null && json_last_error() === JSON_ERROR_NONE ) {
1642            return true;
1643        }
1644        $decoded = json_decode( stripslashes( $string ) );
1645        return $decoded !== null && json_last_error() === JSON_ERROR_NONE;
1646    }
1647
1648    /**
1649     * Parse the content in the v2 format.
1650     *
1651     * V2 Format was a short lived format that accidently contains slash escaped unicode characters.
1652     *
1653     * @param string $post_content The post content to parse.
1654     *
1655     * @return array Parsed fields.
1656     */
1657    private function parse_content_v2( $post_content = '' ) {
1658        $decoded_content = json_decode( $post_content, true );
1659        if ( $decoded_content === null ) {
1660            // If JSON decoding still fails, try with stripslashes and trim as a fallback
1661            // This is a workaround for some cases where the JSON data is not properly formatted
1662            $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1663        }
1664
1665        if ( $decoded_content === null ) {
1666            // Final fallback: attempt to fix malformed JSON with unescaped quotes
1667            // Apply stripslashes first, then fix remaining issues
1668            $stripped_content = stripslashes( trim( $post_content ) );
1669            $fixed_content    = self::fix_malformed_json( $stripped_content );
1670            $decoded_content  = json_decode( $fixed_content, true );
1671        }
1672
1673        if ( $decoded_content === null ) {
1674            return array();
1675        }
1676        $fields = array();
1677        foreach ( $decoded_content['fields'] as $field ) {
1678            $feedback_field = Feedback_Field::from_serialized_v2( $field );
1679            if ( $feedback_field instanceof Feedback_Field ) {
1680                $fields[ $feedback_field->get_key() ] = $feedback_field;
1681                if ( ! $this->has_file && $feedback_field->has_file() ) {
1682                    $this->has_file = true;
1683                }
1684            }
1685        }
1686        $decoded_content['fields'] = $fields;
1687        return $decoded_content;
1688    }
1689
1690    /**
1691     * Parse the content in the v3 format.
1692     *
1693     * @param string $post_content The post content to parse.
1694     *
1695     * @return array Parsed fields.
1696     */
1697    private function parse_content_v3( $post_content = '' ) {
1698        $decoded_content = json_decode( $post_content, true );
1699        if ( $decoded_content === null ) {
1700            // If JSON decoding fails, try to decode the second try with stripslashes and trim.
1701            // This is a workaround for some cases where the JSON data is not properly formatted.
1702            $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
1703        }
1704        if ( $decoded_content === null ) {
1705            return array();
1706        }
1707        $fields = array();
1708        foreach ( $decoded_content['fields'] as $field ) {
1709            $feedback_field = Feedback_Field::from_serialized( $field );
1710            if ( $feedback_field instanceof Feedback_Field ) {
1711                $fields[ $feedback_field->get_key() ] = $feedback_field;
1712                if ( ! $this->has_file && $feedback_field->has_file() ) {
1713                    $this->has_file = true;
1714                }
1715            }
1716        }
1717        $decoded_content['fields'] = $fields;
1718        return $decoded_content;
1719    }
1720
1721    /**
1722     * Parse the legacy content format.
1723     *
1724     * @param string $post_content The post content to parse.
1725     *
1726     * @return array Parsed fields.
1727     */
1728    private function parse_legacy_content( $post_content = '' ) {
1729        $content_parts   = $this->split_legacy_content( $post_content );
1730        $comment_content = $content_parts['comment_content'];
1731        $field_content   = $content_parts['field_content'];
1732
1733        $all_values = $this->extract_legacy_values( $field_content );
1734        $lines      = $this->extract_legacy_lines( $field_content );
1735
1736        $decoded_fields           = array();
1737        $decoded_fields['fields'] = array();
1738
1739        // Process lines for specific field types
1740        $this->process_legacy_lines( $lines, $decoded_fields );
1741
1742        // Process all other values
1743        $this->process_legacy_values( $all_values, $decoded_fields );
1744
1745        // Add comment content field
1746        $this->add_comment_content_field( $comment_content, $decoded_fields );
1747
1748        return $decoded_fields;
1749    }
1750
1751    /**
1752     * Attempt to fix malformed JSON by escaping unescaped quotes in string values.
1753     *
1754     * This method handles cases where JSON contains unescaped quotes within string values,
1755     * which causes json_decode to fail.
1756     *
1757     * @param string $json malformed JSON string.
1758     * @return string The JSON string with escaped quotes.
1759     */
1760    public static function fix_malformed_json( $json ) {
1761
1762        $find    = array();
1763        $replace = array();
1764
1765        // Start of JSON object
1766        $find[]    = '{\"';
1767        $replace[] = '{"';
1768
1769        // Key-value separator
1770        $find[]    = '\":\"';
1771        $replace[] = '":"';
1772
1773        $find[]    = '\\\"';
1774        $replace[] = '\"';
1775
1776        $find[]    = '\":[\"';
1777        $replace[] = '":["';
1778
1779        $find[]    = '\"],';
1780        $replace[] = '"],';
1781
1782        $find[]    = ',[\"';
1783        $replace[] = ',["';
1784
1785        $find[]    = '\",\"';
1786        $replace[] = '","';
1787
1788        $find[]    = ',\"';
1789        $replace[] = ',"';
1790
1791        $find[]    = '\", \"';
1792        $replace[] = '", "';
1793
1794        $find[]    = '\"],\"';
1795        $replace[] = '"],"';
1796
1797        $find[]    = '\"],"';
1798        $replace[] = '"],"';
1799
1800        $find[]    = '\":[]';
1801        $replace[] = '":[]';
1802
1803        $find[]    = '\"]}';
1804        $replace[] = '"]}';
1805
1806        $find[]    = '\":[';
1807        $replace[] = '":[';
1808
1809        $find[]    = '\":{';
1810        $replace[] = '":{';
1811
1812        $find[]    = '\":true';
1813        $replace[] = '":true';
1814
1815        $find[]    = '\":false';
1816        $replace[] = '":false';
1817
1818        $find[]    = '\":null';
1819        $replace[] = '":null';
1820
1821        for ( $i = 0; $i <= 9; $i++ ) {
1822            $find[]    = '\":' . $i;
1823            $replace[] = '":' . $i;
1824
1825            $find[]    = '\",' . $i;
1826            $replace[] = '",' . $i;
1827        }
1828
1829        $find[]    = '\",true';
1830        $replace[] = '",true';
1831
1832        $find[]    = '\",false';
1833        $replace[] = '",false';
1834
1835        $find[]    = '\",null';
1836        $replace[] = '",null';
1837
1838        $find[]    = "\'";
1839        $replace[] = "'";
1840
1841        // End of Json object
1842        $find[]    = '\"}';
1843        $replace[] = '"}';
1844
1845        // Remove any slashes that are there to start a new string.
1846        return str_replace( $find, $replace, addslashes( $json ) );
1847    }
1848
1849    /**
1850     * Split legacy content into comment and field sections.
1851     *
1852     * @param string $post_content The post content to parse.
1853     * @return array Array with 'comment_content' and 'field_content' keys.
1854     */
1855    private function split_legacy_content( $post_content ) {
1856        $content         = explode( '<!--more-->', $post_content );
1857        $comment_content = '';
1858        $field_content   = '';
1859
1860        if ( count( $content ) > 1 ) {
1861            $comment_content = $content[0];
1862            $field_content   = str_ireplace( array( '<br />', ')</p>' ), '', $content[1] );
1863        }
1864
1865        return array(
1866            'comment_content' => $comment_content,
1867            'field_content'   => $field_content,
1868        );
1869    }
1870
1871    /**
1872     * Extract values from legacy field content.
1873     *
1874     * @param string $field_content The field content to parse.
1875     * @return array Extracted values.
1876     */
1877    private function extract_legacy_values( $field_content ) {
1878        $all_values = array();
1879
1880        if ( str_contains( $field_content, 'JSON_DATA' ) ) {
1881            $all_values = $this->parse_json_data( $field_content );
1882        } else {
1883            $all_values = $this->parse_array_format( $field_content );
1884        }
1885
1886        // Ensure all_values is always an array
1887        if ( ! is_array( $all_values ) ) {
1888            $all_values = array();
1889        }
1890
1891        return $all_values;
1892    }
1893
1894    /**
1895     * Extract lines from legacy field content.
1896     *
1897     * @param string $field_content The field content to parse.
1898     * @return array Filtered lines.
1899     */
1900    private function extract_legacy_lines( $field_content ) {
1901        if ( str_contains( $field_content, 'JSON_DATA' ) ) {
1902            $chunks = explode( "\nJSON_DATA", $field_content );
1903            return array_filter( explode( "\n", $chunks[0] ) );
1904        } else {
1905            return array_filter( explode( "\n", $field_content ) );
1906        }
1907    }
1908
1909    /**
1910     * Parse JSON data from field content.
1911     *
1912     * @param string $field_content The field content containing JSON data.
1913     * @return array Parsed JSON data.
1914     */
1915    private function parse_json_data( $field_content ) {
1916        $chunks = explode( "\nJSON_DATA", $field_content );
1917
1918        if ( ! isset( $chunks[1] ) ) {
1919            // Try with 'JSON_DATA' without the newline as a fallback.
1920            $chunks = explode( 'JSON_DATA', $field_content );
1921            if ( ! isset( $chunks[1] ) ) {
1922                // If JSON_DATA is still not found, return an empty array.
1923                return array();
1924            }
1925        }
1926
1927        $json_data = $chunks[1];
1928
1929        $all_values = json_decode( $json_data, true );
1930
1931        if ( $all_values === null ) {
1932            // Fallback for improperly formatted JSON
1933            $all_values = json_decode( stripslashes( trim( $json_data ) ), true );
1934        }
1935
1936        return $all_values === null ? array() : $all_values;
1937    }
1938
1939    /**
1940     * Parse array format from field content.
1941     *
1942     * @param string $field_content The field content in array format.
1943     * @return array Parsed array data.
1944     */
1945    private function parse_array_format( $field_content ) {
1946        $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $field_content );
1947
1948        // Parse key-value pairs formatted as [Key] => Value
1949        preg_match_all( '/^\s*\[([^\]]+)\] =\&gt\; (.*)(?=^\s*(\[[^\]]+\] =\&gt\;)|\z)/msU', $fields_array, $matches );
1950
1951        if ( count( $matches ) > 1 ) {
1952            return array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) );
1953        }
1954
1955        return array();
1956    }
1957
1958    /**
1959     * Process legacy lines into field objects.
1960     *
1961     * We do this so that we can extract specific fields but we don't display the values in the UI.
1962     *
1963     * @param array $lines The lines to process.
1964     * @param array &$decoded_fields Reference to the decoded fields array.
1965     */
1966    private function process_legacy_lines( $lines, &$decoded_fields ) {
1967        $var_map = array(
1968            'AUTHOR'       => array(
1969                'type'  => 'name',
1970                'label' => 'Author',
1971            ),
1972            'AUTHOR EMAIL' => array(
1973                'type'  => 'email',
1974                'label' => 'Email',
1975            ),
1976            'AUTHOR URL'   => array(
1977                'type'  => 'url',
1978                'label' => 'Url',
1979            ),
1980            'SUBJECT'      => array(
1981                'type'  => 'subject',
1982                'label' => 'Subject',
1983            ),
1984            'IP'           => array(
1985                'type'  => 'ip',
1986                'label' => 'IP',
1987            ),
1988        );
1989
1990        foreach ( $lines as $line ) {
1991            $line_parts = explode( ': ', $line, 2 );
1992
1993            if ( count( $line_parts ) !== 2 ) {
1994                continue;
1995            }
1996
1997            list( $key, $value ) = $line_parts;
1998
1999            if ( ! empty( $key ) && isset( $var_map[ $key ] ) ) {
2000                $map_to_field = $var_map[ $key ];
2001                $value        = Contact_Form_Plugin::strip_tags( trim( $value ) );
2002
2003                $decoded_fields['fields'][ $key ] = new Feedback_Field(
2004                    $key,
2005                    $map_to_field['label'],
2006                    $value,
2007                    $map_to_field['type'],
2008                    array( 'render' => false )
2009                );
2010            }
2011        }
2012    }
2013
2014    /**
2015     * Check if the field is a legacy file upload.
2016     *
2017     * @param array $field The field to check.
2018     *
2019     * @return bool True if it's a legacy file upload, false otherwise.
2020     */
2021    private function is_legacy_file_upload( $field ) {
2022        return (
2023            is_array( $field ) &&
2024            ! empty( $field['field_id'] ) &&
2025            isset( $field['files'] ) &&
2026            is_array( $field['files'] )
2027        );
2028    }
2029
2030    /**
2031     * Process legacy values into field objects.
2032     *
2033     * @param array $all_values The values to process.
2034     * @param array &$decoded_fields Reference to the decoded fields array.
2035     */
2036    private function process_legacy_values( $all_values, &$decoded_fields ) {
2037        $non_user_fields = array(
2038            'email_marketing_consent',
2039            'entry_title',
2040            'entry_permalink',
2041            'entry_page',
2042            'feedback_id',
2043        );
2044
2045        foreach ( $all_values as $key => $value ) {
2046            $key   = wp_strip_all_tags( $key );
2047            $label = self::extract_label_from_key( $key );
2048
2049            if ( in_array( $key, $non_user_fields, true ) ) {
2050                if ( $key === 'email_marketing_consent' ) {
2051                    $decoded_fields['fields'][ $key ] = new Feedback_Field(
2052                        $key,
2053                        $label,
2054                        $value,
2055                        'consent',
2056                        array( 'render' => false )
2057                    );
2058                    continue;
2059                }
2060                $decoded_fields[ $key ] = $value;
2061                continue;
2062            }
2063
2064            // check for file upload data and then set it as a file type field.
2065            if ( $this->is_legacy_file_upload( $value ) ) {
2066                // If the value is a file upload, we need to handle it differently.
2067                $decoded_fields['fields'][ $key ] = new Feedback_Field(
2068                    $key,
2069                    $label,
2070                    $value,
2071                    'file'
2072                );
2073                $this->has_file                   = ! empty( $value['files'] ); // Set has_file to true if any file upload is found.
2074            } else {
2075                $decoded_fields['fields'][ $key ] = new Feedback_Field( $key, $label, $value );
2076            }
2077        }
2078    }
2079
2080    /**
2081     * Add comment content as a field.
2082     *
2083     * @param string $comment_content The comment content.
2084     * @param array  &$decoded_fields Reference to the decoded fields array.
2085     */
2086    private function add_comment_content_field( $comment_content, &$decoded_fields ) {
2087        $decoded_fields['fields']['comment_content'] = new Feedback_Field(
2088            'comment_content',
2089            'Comment Content',
2090            trim( Contact_Form_Plugin::strip_tags( $comment_content ) ),
2091            'textarea',
2092            array( 'render' => false )
2093        );
2094    }
2095
2096    /**
2097     * Extract the label from a key that might be in the format "1_label".
2098     *
2099     * @param string $key The key to extract the label from.
2100     * @return string The extracted label.
2101     */
2102    private static function extract_label_from_key( $key ) {
2103        // Check if the key starts with a number followed by underscore and has content after underscore
2104        if ( preg_match( '/^\d+_(.+)$/', $key, $matches ) ) {
2105            return $matches[1];
2106        }
2107        // If the key is just a number followed by underscore (like "2_"), return empty string
2108        if ( preg_match( '/^\d+_$/', $key ) ) {
2109            return '';
2110        }
2111        // If the key doesn't start with a number followed by underscore, return the key as is
2112        return $key;
2113    }
2114
2115    /**
2116     * Get field-specific metadata based on the field type.
2117     *
2118     * @param Contact_Form_Field $field The field object.
2119     * @param string             $type  The field type.
2120     * @return array Metadata array for the field.
2121     */
2122    public static function get_field_meta( $field, $type ) {
2123        $meta = array();
2124
2125        if ( $type === 'rating' ) {
2126            $icon_style        = $field->get_attribute( 'iconstyle' );
2127            $max               = $field->get_attribute( 'max' );
2128            $meta['iconStyle'] = ! empty( $icon_style ) ? $icon_style : 'stars';
2129            $meta['maxRating'] = is_numeric( $max ) && (int) $max > 0 ? (int) $max : 5;
2130        }
2131
2132        return $meta;
2133    }
2134
2135    /**
2136     * Get all the fields of the response, computed from the post data.
2137     *
2138     * @param array        $post_data The post data from the form submission.
2139     * @param Contact_Form $form The form object.
2140     * @return array An array of Feedback_Field objects.
2141     */
2142    private function get_computed_fields( $post_data, $form ) {
2143
2144        $fields = array();
2145
2146        $field_ids = $form->get_field_ids();
2147        // For all fields, grab label and value
2148        $i = 1;
2149        foreach ( $field_ids['all'] as $field_id ) {
2150            $field = $form->fields[ $field_id ];
2151            $type  = $field->get_attribute( 'type' );
2152            if ( ! $field->is_field_renderable( $type ) ) {
2153                continue;
2154            }
2155
2156            $value = $this->get_field_value( $field_id, $post_data, $type );
2157            $label = wp_strip_all_tags( $field->get_attribute( 'label' ) );
2158            $key   = $i . '_' . $label;
2159
2160            $meta = self::get_field_meta( $field, $type );
2161
2162            // Process radio fields to detect and extract "Other" option metadata
2163            if ( $type === 'radio' ) {
2164                $processed = $this->process_radio_field_value( $value, $field, $field_id, $post_data );
2165                $value     = $processed['value'];
2166                $meta      = array_merge( $meta, $processed['meta'] );
2167            }
2168            $fields[ $key ] = new Feedback_Field( $key, $label, $value, $type, $meta, $field_id );
2169            if ( ! $this->has_file && $fields[ $key ]->has_file() ) {
2170                $this->has_file = true;
2171            }
2172            ++$i; // Increment prefix counter for the next field.
2173        }
2174
2175        return $fields;
2176    }
2177
2178    /**
2179     * Gets the computed subject.
2180     *
2181     * @param array        $post_data The post data from the form submission.
2182     * @param Contact_Form $form The form object.
2183     * @return string
2184     */
2185    private function get_computed_subject( $post_data, $form ) {
2186
2187        $contact_form_subject = $form->get_attribute( 'subject' );
2188        $field_ids            = $form->get_field_ids();
2189
2190        if ( isset( $field_ids['subject'] ) ) {
2191            $value = $this->get_field_value( $field_ids['subject'], $post_data );
2192            if ( ! empty( $value ) ) {
2193                $contact_form_subject = $value;
2194            }
2195        }
2196
2197        return apply_filters( 'contact_form_subject', $contact_form_subject, $this->get_all_values() );
2198    }
2199
2200    /**
2201     * Gets the computed comment content.
2202     *
2203     * @param array        $post_data The post data from the form submission.
2204     * @param Contact_Form $form The form object.
2205     * @return string
2206     */
2207    private function get_computed_comment_content( $post_data, $form ) {
2208        $field_ids = $form->get_field_ids();
2209        if ( isset( $field_ids['textarea'] ) ) {
2210            $value = $this->get_field_value( $field_ids['textarea'], $post_data );
2211            if ( is_string( $value ) ) {
2212                return trim( Contact_Form_Plugin::strip_tags( stripslashes( $value ) ) );
2213            }
2214        }
2215        return '';
2216    }
2217
2218    /**
2219     * Gets the computed consent.
2220     *
2221     * @param array        $post_data The post data from the form submission.
2222     * @param Contact_Form $form The form object.
2223     * @return bool
2224     */
2225    private function get_computed_consent( $post_data, $form ) {
2226        $field_ids = $form->get_field_ids();
2227
2228        if ( isset( $field_ids['email_marketing_consent_field'] ) && $field_ids['email_marketing_consent_field'] !== null ) {
2229            return (bool) $this->get_field_value( $field_ids['email_marketing_consent_field'], $post_data );
2230        }
2231
2232        return false;
2233    }
2234
2235    /**
2236     * Gets the computed notification recipients.
2237     *
2238     * @since 6.10.0
2239     *
2240     * @param array        $post_data The post data from the form submission.
2241     * @param Contact_Form $form The form object.
2242     * @return array
2243     */
2244    private function get_computed_notification_recipients( $post_data, $form ) {
2245        $notification_recipients = $form->get_attribute( 'notificationRecipients' );
2246        return $this->validate_notification_recipients( $notification_recipients );
2247    }
2248
2249    /**
2250     * Validates notification recipients have proper capabilities.
2251     *
2252     * Ensures each user ID corresponds to a real user with edit_posts or edit_pages capability.
2253     * Filters out invalid or unauthorized user IDs.
2254     *
2255     * @since 6.10.0
2256     *
2257     * @param array $recipients Array of user IDs.
2258     * @return array Array of validated user IDs.
2259     */
2260    private function validate_notification_recipients( $recipients ) {
2261        if ( ! is_array( $recipients ) ) {
2262            return array();
2263        }
2264
2265        $valid_recipients = array();
2266        foreach ( $recipients as $user_id ) {
2267            $user = get_userdata( $user_id );
2268            // Only allow users with edit_posts or edit_pages capability
2269            if ( $user && ( $user->has_cap( 'edit_posts' ) || $user->has_cap( 'edit_pages' ) ) ) {
2270                $valid_recipients[] = $user_id;
2271            }
2272        }
2273
2274        return $valid_recipients;
2275    }
2276
2277    /**
2278     * Get a field by its original form ID.
2279     *
2280     * @since 5.5.0
2281     *
2282     * @param string $id Original form field ID.
2283     * @return Feedback_Field|null
2284     */
2285    public function get_field_by_form_field_id( $id ) {
2286        if ( ! is_string( $id ) || $id === '' ) {
2287            return null;
2288        }
2289        foreach ( $this->fields as $field ) {
2290            if ( $field->get_form_field_id() === $id ) {
2291                return $field;
2292            }
2293        }
2294        return null;
2295    }
2296
2297    /**
2298     * Get a field render value by its original form ID.
2299     *
2300     * @since 5.5.0
2301     *
2302     * @param string $id Original form field ID.
2303     * @param string $context Render context.
2304     * @return string
2305     */
2306    public function get_field_value_by_form_field_id( $id, $context = 'default' ) {
2307        $field = $this->get_field_by_form_field_id( $id );
2308        if ( ! $field ) {
2309            return '';
2310        }
2311        return (string) $field->get_render_value( $context );
2312    }
2313}