Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.04% covered (warning)
72.04%
67 / 93
57.14% covered (warning)
57.14%
12 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Abstract_Csv_Report_Controller
72.83% covered (warning)
72.83%
67 / 92
57.14% covered (warning)
57.14%
12 / 21
103.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 format_row_with_comparison
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_report_key
n/a
0 / 0
n/a
0 / 0
0
 get_report_label
n/a
0 / 0
n/a
0 / 0
0
 get_data_endpoint
n/a
0 / 0
n/a
0 / 0
0
 get_column_headers
n/a
0 / 0
n/a
0 / 0
0
 format_row_for_csv
n/a
0 / 0
n/a
0 / 0
0
 get_default_values
n/a
0 / 0
n/a
0 / 0
0
 get_batch_limit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_additional_params
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_fields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_matching_field
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_identifying_fields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 use_array_filter_format
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_include_empty_rows_by_default
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_include_empty_rows
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_empty_row_label
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_empty_row_check_field
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 format_time_interval
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 format_amount
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_interval_label
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 extract_data_by_prefix
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 format_row_with_empty_handling
52.94% covered (warning)
52.94%
9 / 17
0.00% covered (danger)
0.00%
0 / 1
20.42
 is_row_empty
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 apply_empty_row_label
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 add_comparison_fields
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Abstract CSV Report Controller
4 *
5 * Base class for the per-report CSV export controllers registered in Report_Registry.
6 *
7 * @package Automattic\Jetpack\PremiumAnalytics\Reports\Export
8 */
9
10declare( strict_types=1 );
11
12namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export;
13
14defined( 'ABSPATH' ) || exit;
15
16use DateTime;
17use Exception;
18
19/**
20 * Abstract base class for CSV report controllers.
21 *
22 * @since 0.1.0
23 */
24abstract class Abstract_Csv_Report_Controller implements Csv_Report_Controller_Interface {
25
26    /**
27     * Default batch limit for time-based reports.
28     */
29    protected const DEFAULT_BATCH_LIMIT = 1000;
30
31    /**
32     * Report registry instance.
33     *
34     * @var Report_Registry
35     */
36    protected $registry;
37
38    /**
39     * Cached result of should_include_empty_rows() to avoid repeated filter calls.
40     *
41     * @var bool|null
42     */
43    private $cached_include_empty_rows = null;
44
45    /**
46     * Constructor.
47     *
48     * @param Report_Registry $registry Registry instance (injected by DI).
49     */
50    public function __construct( Report_Registry $registry ) {
51        $this->registry = $registry;
52    }
53
54    /**
55     * Register this controller with the report registry.
56     *
57     * @return void
58     */
59    public function register(): void {
60        $this->registry->register_controller( $this );
61    }
62
63    /**
64     * Format a row with automatic comparison field handling.
65     *
66     * @param array       $item     The raw data item.
67     * @param string|null $interval Optional time interval for formatting.
68     * @return array The formatted row with comparison fields.
69     */
70    public function format_row_with_comparison( array $item, ?string $interval = null ): array {
71        $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX;
72
73        // Extract original data (fields without comparison_ prefix) to check for empty values.
74        // Note: We format the full $item, not the extracted subset, to preserve all data.
75        $original_data = $this->extract_data_by_prefix( $item, $prefix, false );
76        $row           = $this->format_row_with_empty_handling( $original_data, $item, $interval );
77
78        return $this->add_comparison_fields( $row, $item, $interval );
79    }
80
81    /**
82     * Get the report key (unique identifier).
83     *
84     * @return string
85     */
86    abstract public function get_report_key(): string;
87
88    /**
89     * Get the report label (human-readable name).
90     *
91     * @return string
92     */
93    abstract public function get_report_label(): string;
94
95    /**
96     * Get the data endpoint (API route).
97     *
98     * @return string
99     */
100    abstract public function get_data_endpoint(): string;
101
102    /**
103     * Get the column headers for CSV export.
104     *
105     * @param string|null $interval Optional time interval for dynamic headers.
106     * @return array
107     */
108    abstract public function get_column_headers( ?string $interval = null ): array;
109
110    /**
111     * Format a single data item for CSV export.
112     *
113     * Return the base row only; format_row_with_comparison() adds the comparison fields.
114     *
115     * @param array       $item     The raw data item.
116     * @param string|null $interval Optional time interval for formatting.
117     * @return array The formatted row for CSV.
118     */
119    abstract public function format_row_for_csv( array $item, ?string $interval = null ): array;
120
121    /**
122     * Get default values for missing data fields.
123     *
124     * Used to build empty items when comparison data is missing.
125     *
126     * @return array Array of field_name => default_value pairs.
127     */
128    abstract public function get_default_values(): array;
129
130    /**
131     * Get the batch limit (max items per request).
132     *
133     * @return int
134     */
135    public function get_batch_limit(): int {
136        return self::DEFAULT_BATCH_LIMIT;
137    }
138
139    /**
140     * Get additional request parameters for data fetching.
141     *
142     * @return array Additional parameters to include in data requests.
143     */
144    public function get_additional_params(): array {
145        return array();
146    }
147
148    /**
149     * Get the list of fields to request from the API.
150     *
151     * Override to request only the fields this report needs, shrinking the API response.
152     *
153     * @return array Field names to request, or empty array for all fields.
154     */
155    public function get_fields(): array {
156        return array();
157    }
158
159    /**
160     * Get the matching field for comparison data alignment.
161     *
162     * Default: null (index-based matching for time-series reports).
163     * Override in child classes for ID-based matching (ranked reports).
164     *
165     * @return string|null
166     */
167    public function get_matching_field(): ?string {
168        return null;
169    }
170
171    /**
172     * Get the identifying fields that should be preserved in comparison data.
173     *
174     * Default: empty array (no fields preserved for time-series reports). Ranked reports name
175     * identifying fields here so they are copied over when comparison data is missing.
176     *
177     * @return array Array of field names to preserve.
178     */
179    public function get_identifying_fields(): array {
180        return array();
181    }
182
183    /**
184     * Whether to use array format for filter values in IN filters.
185     *
186     * Default: false (comma-separated format for URL efficiency).
187     * Override in controllers that require array format (e.g., order-attribution).
188     *
189     * @return bool True to use array format, false for comma-separated (default).
190     */
191    public function use_array_filter_format(): bool {
192        return false;
193    }
194
195    /**
196     * Whether to include empty rows by default.
197     *
198     * @return bool True to include empty rows, false to exclude them.
199     */
200    protected function should_include_empty_rows_by_default(): bool {
201        return true;
202    }
203
204    /**
205     * Whether to include rows with empty identifying fields in the export.
206     *
207     * The controller's default, overridable via 'jetpack_premium_analytics_csv_include_empty_rows';
208     * cached per instance since it's consulted once per row.
209     *
210     * @return bool True to include empty rows with custom label, false to skip them.
211     */
212    public function should_include_empty_rows(): bool {
213        if ( null !== $this->cached_include_empty_rows ) {
214            return $this->cached_include_empty_rows;
215        }
216
217        $default = $this->should_include_empty_rows_by_default();
218
219        /**
220         * Filter whether to include empty rows in CSV exports.
221         *
222         * This filter takes precedence over controller defaults, allowing global control.
223         *
224         * @param bool   $include_empty Whether to include empty rows (controller default).
225         * @param string $report_key    The report key for this controller.
226         * @param object $controller    The controller instance.
227         */
228        $this->cached_include_empty_rows = apply_filters( 'jetpack_premium_analytics_csv_include_empty_rows', $default, $this->get_report_key(), $this );
229
230        return $this->cached_include_empty_rows;
231    }
232
233    /**
234     * Get the label to use for rows with empty identifying fields.
235     *
236     * Only used when should_include_empty_rows() returns true.
237     *
238     * @return string The label to use for empty rows.
239     */
240    public function get_empty_row_label(): string {
241        return __( 'Unassigned', 'jetpack-premium-analytics-pkg' );
242    }
243
244    /**
245     * Get the field name(s) to check for emptiness when determining if a row should be skipped.
246     *
247     * Default: null (no empty row checking). When an array is returned, every named field
248     * must be empty before the row counts as empty.
249     *
250     * @return array|null Array of field name(s) to check, or null to skip empty checking.
251     */
252    public function get_empty_row_check_field() {
253        return null;
254    }
255
256    /**
257     * Format a time interval for display in CSV.
258     *
259     * @param array       $item     The data item containing date_start.
260     * @param string|null $interval Optional time interval. If 'hour', formats as 'Y-m-d H:00', otherwise 'Y-m-d'.
261     * @return string The formatted date string.
262     */
263    protected function format_time_interval( array $item, ?string $interval = null ): string {
264        if ( ! isset( $item['date_start'] ) || empty( $item['date_start'] ) ) {
265            return '';
266        }
267
268        try {
269            $datetime = new DateTime( $item['date_start'] );
270            $format   = ( 'hour' === $interval ) ? 'Y-m-d H:00' : 'Y-m-d';
271            return $datetime->format( $format );
272        } catch ( Exception $e ) {
273            return '';
274        }
275    }
276
277    /**
278     * Format a monetary amount for display in CSV.
279     *
280     * @param mixed $amount The amount to format.
281     * @return string The formatted amount.
282     */
283    protected static function format_amount( $amount ): string {
284        if ( is_numeric( $amount ) ) {
285            return number_format( (float) $amount, 2, '.', '' );
286        }
287        return '0.00';
288    }
289
290    /**
291     * Get the label for a time interval.
292     *
293     * @param string|null $interval The time interval (hour, day, week, month, year).
294     * @return string The translated interval label.
295     */
296    protected function get_interval_label( ?string $interval = null ): string {
297        $labels = array(
298            'hour'  => __( 'Hour', 'jetpack-premium-analytics-pkg' ),
299            'day'   => __( 'Day', 'jetpack-premium-analytics-pkg' ),
300            'week'  => __( 'Week', 'jetpack-premium-analytics-pkg' ),
301            'month' => __( 'Month', 'jetpack-premium-analytics-pkg' ),
302            'year'  => __( 'Year', 'jetpack-premium-analytics-pkg' ),
303        );
304
305        return $labels[ $interval ?? '' ] ?? __( 'Date', 'jetpack-premium-analytics-pkg' );
306    }
307
308    /**
309     * Extract data from an item based on prefix matching.
310     *
311     * @param array  $item          The raw data item.
312     * @param string $prefix        The prefix to match against.
313     * @param bool   $match_prefix  If true, include keys with prefix; if false, exclude keys with prefix.
314     * @param bool   $strip_prefix  If true, strip the prefix from extracted keys.
315     * @return array Extracted data item and a flag indicating if all values are empty.
316     */
317    protected function extract_data_by_prefix( array $item, string $prefix, bool $match_prefix, bool $strip_prefix = false ): array {
318        $extracted_item = array();
319        $all_empty      = true;
320        $prefix_length  = strlen( $prefix );
321
322        foreach ( $item as $key => $value ) {
323            $has_prefix = ( strpos( $key, $prefix ) === 0 );
324
325            if ( $has_prefix === $match_prefix ) {
326                $extracted_key                    = $strip_prefix && $has_prefix ? substr( $key, $prefix_length ) : $key;
327                $extracted_item[ $extracted_key ] = $value;
328
329                if ( '' !== $value ) {
330                    $all_empty = false;
331                }
332            }
333        }
334
335        return array(
336            'item'      => $extracted_item,
337            'all_empty' => $all_empty,
338        );
339    }
340
341    /**
342     * Format a row with empty value handling.
343     *
344     * A row whose values are all empty strings keeps them, rather than picking up the
345     * controller's defaults — "no data" and "zero" must not collapse into each other.
346     *
347     * @param array       $extracted_data The extracted data item and empty flag.
348     * @param array       $item_to_format The item to use for formatting (may differ from extracted data).
349     * @param string|null $interval       Optional time interval for formatting.
350     * @return array The formatted row, or empty array if row should be skipped.
351     */
352    protected function format_row_with_empty_handling( array $extracted_data, array $item_to_format, ?string $interval = null ): array {
353        $item      = $extracted_data['item'];
354        $all_empty = $extracted_data['all_empty'];
355
356        $check_fields = $this->get_empty_row_check_field();
357        $is_empty     = ( null !== $check_fields ) ? $this->is_row_empty( $item, $check_fields ) : false;
358
359        if ( $is_empty ) {
360            if ( ! $this->should_include_empty_rows() ) {
361                // An empty array is the caller's "skip this row" signal.
362                return array();
363            }
364            // Otherwise keep the row; the empty identifying field gets the custom label below.
365        }
366
367        if ( $all_empty && ! empty( $item ) ) {
368            $row_structure = $this->format_row_for_csv( array(), $interval );
369            $row           = array();
370            foreach ( array_keys( $row_structure ) as $key ) {
371                $row[ $key ] = '';
372            }
373            return $row;
374        }
375
376        $row = $this->format_row_for_csv( $item_to_format, $interval );
377
378        // $is_empty implies $check_fields is non-null (see above), but check explicitly for the type checker.
379        if ( $is_empty && null !== $check_fields && $this->should_include_empty_rows() ) {
380            $row = $this->apply_empty_row_label( $row, $check_fields );
381        }
382
383        return $row;
384    }
385
386    /**
387     * Check if a row is considered empty based on the configured check field(s).
388     *
389     * @param array $item         The data item to check.
390     * @param array $check_fields Array of field names to check for emptiness.
391     * @return bool True if the row is empty, false otherwise.
392     */
393    protected function is_row_empty( array $item, array $check_fields ): bool {
394        // All specified fields must be empty for the row to be considered empty.
395        foreach ( $check_fields as $field ) {
396            // Use strict comparison to avoid treating '0' as empty.
397            if ( isset( $item[ $field ] ) && '' !== $item[ $field ] ) {
398                return false;
399            }
400        }
401
402        return true;
403    }
404
405    /**
406     * Apply the custom empty row label to the formatted row.
407     *
408     * Only the first empty check field (the primary identifier column) gets the label; child
409     * classes that rename the field while formatting must override this.
410     *
411     * @param array $row          The formatted row data.
412     * @param array $check_fields The field names that were empty.
413     * @return array The row with custom label applied.
414     */
415    protected function apply_empty_row_label( array $row, array $check_fields ): array {
416        $custom_label = $this->get_empty_row_label();
417
418        foreach ( $check_fields as $field_name ) {
419            if ( isset( $row[ $field_name ] ) && '' === $row[ $field_name ] ) {
420                $row[ $field_name ] = $custom_label;
421                return $row;
422            }
423        }
424
425        return $row;
426    }
427
428    /**
429     * Automatically add comparison fields to a formatted row.
430     *
431     * Comparison data round-trips through format_row_for_csv() with the prefix stripped and
432     * re-added, so field name mapping (orders_value_net → net_sales) applies to it too.
433     *
434     * @param array       $row      The formatted row with original data.
435     * @param array       $item     The raw item with both original and comparison data.
436     * @param string|null $interval Optional time interval for formatting.
437     * @return array The row with comparison fields added.
438     */
439    protected function add_comparison_fields( array $row, array $item, ?string $interval = null ): array {
440        $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX;
441
442        $has_comparison_data = false;
443        foreach ( array_keys( $item ) as $key ) {
444            if ( strpos( $key, $prefix ) === 0 ) {
445                $has_comparison_data = true;
446                break;
447            }
448        }
449
450        if ( ! $has_comparison_data ) {
451            return $row;
452        }
453
454        $comparison_data = $this->extract_data_by_prefix( $item, $prefix, true, true );
455        $comparison_row  = $this->format_row_with_empty_handling( $comparison_data, $comparison_data['item'], $interval );
456
457        foreach ( $comparison_row as $key => $value ) {
458            $row[ $prefix . $key ] = $value;
459        }
460
461        return $row;
462    }
463}