Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.34% covered (warning)
72.34%
68 / 94
57.14% covered (warning)
57.14%
12 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Abstract_Csv_Report_Controller
73.12% covered (warning)
73.12%
68 / 93
57.14% covered (warning)
57.14%
12 / 21
101.53
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%
9 / 9
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, quarter, 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            'quarter' => __( 'Quarter', 'jetpack-premium-analytics-pkg' ),
303            'year'    => __( 'Year', 'jetpack-premium-analytics-pkg' ),
304        );
305
306        return $labels[ $interval ?? '' ] ?? __( 'Date', 'jetpack-premium-analytics-pkg' );
307    }
308
309    /**
310     * Extract data from an item based on prefix matching.
311     *
312     * @param array  $item          The raw data item.
313     * @param string $prefix        The prefix to match against.
314     * @param bool   $match_prefix  If true, include keys with prefix; if false, exclude keys with prefix.
315     * @param bool   $strip_prefix  If true, strip the prefix from extracted keys.
316     * @return array Extracted data item and a flag indicating if all values are empty.
317     */
318    protected function extract_data_by_prefix( array $item, string $prefix, bool $match_prefix, bool $strip_prefix = false ): array {
319        $extracted_item = array();
320        $all_empty      = true;
321        $prefix_length  = strlen( $prefix );
322
323        foreach ( $item as $key => $value ) {
324            $has_prefix = ( strpos( $key, $prefix ) === 0 );
325
326            if ( $has_prefix === $match_prefix ) {
327                $extracted_key                    = $strip_prefix && $has_prefix ? substr( $key, $prefix_length ) : $key;
328                $extracted_item[ $extracted_key ] = $value;
329
330                if ( '' !== $value ) {
331                    $all_empty = false;
332                }
333            }
334        }
335
336        return array(
337            'item'      => $extracted_item,
338            'all_empty' => $all_empty,
339        );
340    }
341
342    /**
343     * Format a row with empty value handling.
344     *
345     * A row whose values are all empty strings keeps them, rather than picking up the
346     * controller's defaults — "no data" and "zero" must not collapse into each other.
347     *
348     * @param array       $extracted_data The extracted data item and empty flag.
349     * @param array       $item_to_format The item to use for formatting (may differ from extracted data).
350     * @param string|null $interval       Optional time interval for formatting.
351     * @return array The formatted row, or empty array if row should be skipped.
352     */
353    protected function format_row_with_empty_handling( array $extracted_data, array $item_to_format, ?string $interval = null ): array {
354        $item      = $extracted_data['item'];
355        $all_empty = $extracted_data['all_empty'];
356
357        $check_fields = $this->get_empty_row_check_field();
358        $is_empty     = ( null !== $check_fields ) ? $this->is_row_empty( $item, $check_fields ) : false;
359
360        if ( $is_empty ) {
361            if ( ! $this->should_include_empty_rows() ) {
362                // An empty array is the caller's "skip this row" signal.
363                return array();
364            }
365            // Otherwise keep the row; the empty identifying field gets the custom label below.
366        }
367
368        if ( $all_empty && ! empty( $item ) ) {
369            $row_structure = $this->format_row_for_csv( array(), $interval );
370            $row           = array();
371            foreach ( array_keys( $row_structure ) as $key ) {
372                $row[ $key ] = '';
373            }
374            return $row;
375        }
376
377        $row = $this->format_row_for_csv( $item_to_format, $interval );
378
379        // $is_empty implies $check_fields is non-null (see above), but check explicitly for the type checker.
380        if ( $is_empty && null !== $check_fields && $this->should_include_empty_rows() ) {
381            $row = $this->apply_empty_row_label( $row, $check_fields );
382        }
383
384        return $row;
385    }
386
387    /**
388     * Check if a row is considered empty based on the configured check field(s).
389     *
390     * @param array $item         The data item to check.
391     * @param array $check_fields Array of field names to check for emptiness.
392     * @return bool True if the row is empty, false otherwise.
393     */
394    protected function is_row_empty( array $item, array $check_fields ): bool {
395        // All specified fields must be empty for the row to be considered empty.
396        foreach ( $check_fields as $field ) {
397            // Use strict comparison to avoid treating '0' as empty.
398            if ( isset( $item[ $field ] ) && '' !== $item[ $field ] ) {
399                return false;
400            }
401        }
402
403        return true;
404    }
405
406    /**
407     * Apply the custom empty row label to the formatted row.
408     *
409     * Only the first empty check field (the primary identifier column) gets the label; child
410     * classes that rename the field while formatting must override this.
411     *
412     * @param array $row          The formatted row data.
413     * @param array $check_fields The field names that were empty.
414     * @return array The row with custom label applied.
415     */
416    protected function apply_empty_row_label( array $row, array $check_fields ): array {
417        $custom_label = $this->get_empty_row_label();
418
419        foreach ( $check_fields as $field_name ) {
420            if ( isset( $row[ $field_name ] ) && '' === $row[ $field_name ] ) {
421                $row[ $field_name ] = $custom_label;
422                return $row;
423            }
424        }
425
426        return $row;
427    }
428
429    /**
430     * Automatically add comparison fields to a formatted row.
431     *
432     * Comparison data round-trips through format_row_for_csv() with the prefix stripped and
433     * re-added, so field name mapping (orders_value_net → net_sales) applies to it too.
434     *
435     * @param array       $row      The formatted row with original data.
436     * @param array       $item     The raw item with both original and comparison data.
437     * @param string|null $interval Optional time interval for formatting.
438     * @return array The row with comparison fields added.
439     */
440    protected function add_comparison_fields( array $row, array $item, ?string $interval = null ): array {
441        $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX;
442
443        $has_comparison_data = false;
444        foreach ( array_keys( $item ) as $key ) {
445            if ( strpos( $key, $prefix ) === 0 ) {
446                $has_comparison_data = true;
447                break;
448            }
449        }
450
451        if ( ! $has_comparison_data ) {
452            return $row;
453        }
454
455        $comparison_data = $this->extract_data_by_prefix( $item, $prefix, true, true );
456        $comparison_row  = $this->format_row_with_empty_handling( $comparison_data, $comparison_data['item'], $interval );
457
458        foreach ( $comparison_row as $key => $value ) {
459            $row[ $prefix . $key ] = $value;
460        }
461
462        return $row;
463    }
464}