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