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