<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Tools;

use PrettyLinks\Tools\ChunkedCsvExporter;

/**
 * CSV exporter base for small, already-aggregated datasets (Pro).
 *
 * The Lite exporters (CsvExporter, ClicksCsvExporter) page a large DB
 * table with their own `LIMIT/OFFSET` query per chunk. Report exporters
 * are different: the dataset is already computed in memory (one row per
 * source link / per split-test variant) and bounded by how many links a
 * report groups, so there is nothing to page at the SQL level.
 *
 * This base bridges that gap. A concrete class implements `columns()` and
 * `computeRows()` (the full in-memory row set); the base memoizes the row
 * set and satisfies the Lite `ChunkedCsvExporter` contract by slicing it.
 * That means report exports reuse the exact same chunk response shape, CSV
 * stitching, spreadsheet-injection escaping, and the shared JS downloader
 * (`src-js/shared/csv-download.js`) as the click/link exports — the
 * datasets are small enough to fit a single chunk in practice, but the
 * contract stays identical so nothing on the client has to special-case
 * them.
 *
 * Lives Pro-side because aggregate-report export is a Pro-only surface;
 * the reusable primitive it extends (`ChunkedCsvExporter`) stays in Lite.
 *
 * Rows are keyed by their column name (the header string), so a concrete
 * class should build each row with the same labels it returns from
 * `columns()`.
 */
abstract class AggregateCsvExporter extends ChunkedCsvExporter
{
    /**
     * Memoized row sets keyed by a hash of the export args, so the three
     * base calls in one `chunk()`/`fullCsv()` pass (columns → totalRows →
     * fetchPage) compute the underlying dataset only once.
     *
     * The cache is per-instance, so it lives only for a single REST request.
     * Report exports are single-chunk by design (a handful of sources /
     * variants — far below `DEFAULT_CHUNK_SIZE`), so the full `computeRows()`
     * runs once per export in practice. A hypothetical report grouping more
     * rows than one chunk would recompute the whole set on each chunk request;
     * that trade is acceptable at these dataset sizes and keeps the class
     * simple, but it's the reason not to reuse this base for large tables.
     *
     * @var array<string, array<int, array<string, mixed>>>
     */
    private array $rowCache = [];

    /**
     * Compute the full, already-aggregated row set for the given args.
     * Each row is an associative array keyed by the column names returned
     * from `columns()`.
     *
     * @param  array<string, mixed> $args Export arguments.
     * @return array<int, array<string, mixed>>
     */
    abstract protected function computeRows(array $args): array;

    /**
     * Memoized accessor for the computed row set.
     *
     * @param  array<string, mixed> $args Export arguments.
     * @return array<int, array<string, mixed>>
     */
    protected function allRows(array $args): array
    {
        $key = md5((string) wp_json_encode($args));
        if (!array_key_exists($key, $this->rowCache)) {
            $this->rowCache[$key] = array_values($this->computeRows($args));
        }
        return $this->rowCache[$key];
    }

    /**
     * Total number of rows in the computed set.
     *
     * @param array<string, mixed> $args Export arguments.
     */
    protected function totalRows(array $args): int
    {
        return count($this->allRows($args));
    }

    /**
     * Return one page of the in-memory row set.
     *
     * @param  array<string, mixed> $args    Export arguments.
     * @param  integer              $offset  Row offset.
     * @param  integer              $limit   Maximum rows to return.
     * @param  string[]             $columns Resolved column list (unused; rows are pre-keyed).
     * @return array<int, array<string, mixed>>
     */
    protected function fetchPage(array $args, int $offset, int $limit, array $columns): array
    {
        return array_slice($this->allRows($args), $offset, $limit);
    }

    /**
     * Escape every cell against spreadsheet formula injection — report
     * rows carry user-controlled strings (link names, target URLs).
     *
     * @param string               $col Column name.
     * @param array<string, mixed> $row Row data keyed by column name.
     */
    protected function formatCell(string $col, array $row): string
    {
        return self::escapeCell((string) ($row[$col] ?? ''));
    }
}
