<?php

declare(strict_types=1);

namespace PpPhp\Core\DataOps;

use PpPhp\Core\DB;
use PpPhp\Core\Events;

/**
 * Runs an export end-to-end for a generated page.
 *
 * export.php is a thin bootstrap that hands us a config array; everything else
 * — the permission guard, the query, lookup resolution, the format dispatch —
 * lives here so new behaviour ships to every generated app without touching the
 * build's string templates.
 *
 * Events (Events module):
 *   dataops.export.before   ['table','format','where','params'] → false cancels
 *   dataops.export.rows     ['table','format','rows']  → return array replaces rows
 *   dataops.export.after    ['table','format','filename','count']
 * The legacy tables.<t>.page.beforeExport / afterExport hooks still fire.
 */
final class ExportRunner
{
    /**
     * Build the WHERE clause an export inherits from the list page: free-text
     * search, per-column filters, an explicit id set ("export selected"), and
     * the row-level sqlWhere security hook. Kept here so export.php stays thin
     * and every caller filters identically.
     *
     * @param list<string> $validFields whitelist — nothing else reaches SQL
     * @return array{0:string, 1:list<mixed>}
     */
    public static function buildWhere(string $table, array $validFields, array $get): array
    {
        $where = ''; $params = [];

        $search = trim((string) ($get['search'] ?? ''));
        if ($search !== '' && $validFields !== []) {
            $likes = [];
            foreach ($validFields as $f) { $likes[] = "`$f` LIKE ?"; $params[] = "%$search%"; }
            $where = 'WHERE (' . implode(' OR ', $likes) . ')';
        }
        // Per-column filters (?f[col]=…). A scalar matches with LIKE contains; an
        // ARRAY is either a RANGE (min/max or from/to → >= / <=, dates include the
        // whole 'to' day) or a MULTI-SELECT list (→ IN). Mirrors the list page's
        // WHERE builder so an export matches exactly what's on screen. Previously
        // an array value was `trim((string) $val)` → the literal "Array" → a bogus
        // `LIKE '%Array%'` (empty/garbage export, plus a PHP warning).
        foreach ((array) ($get['f'] ?? []) as $col => $val) {
            if (!in_array($col, $validFields, true)) continue;
            if (is_array($val)) {
                $isRange = isset($val['min']) || isset($val['max']) || isset($val['from']) || isset($val['to']);
                if ($isRange) {
                    $lo = trim((string) ($val['min'] ?? $val['from'] ?? ''));
                    $hi = trim((string) ($val['max'] ?? $val['to'] ?? ''));
                    $isDate = isset($val['from']) || isset($val['to']);
                    if ($lo !== '') { $where = ($where === '' ? 'WHERE ' : $where . ' AND ') . "`$col` >= ?"; $params[] = $lo; }
                    if ($hi !== '') { $where = ($where === '' ? 'WHERE ' : $where . ' AND ') . ($isDate ? "`$col` < DATE_ADD(?, INTERVAL 1 DAY)" : "`$col` <= ?"); $params[] = $hi; }
                    continue;
                }
                $vals = array_values(array_filter(array_map(static fn($x) => trim((string) $x), $val), static fn($x) => $x !== ''));
                if ($vals === []) continue;
                $where = ($where === '' ? 'WHERE ' : $where . ' AND ') . "`$col` IN (" . implode(',', array_fill(0, count($vals), '?')) . ')';
                foreach ($vals as $vv) $params[] = $vv;
                continue;
            }
            $val = trim((string) $val);
            if ($val === '') continue;
            $params[] = "%$val%";
            $where = ($where === '' ? 'WHERE ' : $where . ' AND ') . "`$col` LIKE ?";
        }
        if (!empty($get['ids'])) {
            // "Export selected": the ticked rows arrive as ?ids=. Keyed on the real
            // PK (not a hardcoded `id`) and bound as params so string/UUID PKs work
            // too. Tables whose PK ≠ `id` (e.g. std_id) previously exported nothing.
            $ids = array_values(array_filter(array_map('trim', explode(',', (string) $get['ids'])), static fn($v) => $v !== ''));
            if ($ids !== []) {
                $pk = function_exists('pp_pk') ? preg_replace('/[^A-Za-z0-9_]/', '', (string) pp_pk($table)) : 'id';
                if ($pk === '') $pk = 'id';
                $where = ($where === '' ? 'WHERE ' : $where . ' AND ') . "`$pk` IN (" . implode(',', array_fill(0, count($ids), '?')) . ')';
                foreach ($ids as $v) $params[] = $v;
            }
        }
        $extra = Events::fire("tables.$table.sql.sqlWhere", ['user' => $_SESSION['user'] ?? [], 'params' => $get]);
        if (is_string($extra) && trim($extra) !== '') {
            $where = ($where === '' ? 'WHERE (' : $where . ' AND (') . $extra . ')';
        }
        return [$where, $params];
    }

    /**
     * @param array{
     *   table:string, columns:array<string,string>, allowed:list<string>,
     *   caption?:string, brand?:string, where?:string, params?:list<mixed>,
     *   lookups?:array<string,mixed>, includeHeaders?:bool, filename?:string
     * } $cfg
     */
    public static function handle(array $cfg): void
    {
        $table   = (string) $cfg['table'];
        $allowed = array_values((array) ($cfg['allowed'] ?? []));
        // An explicit format (from the Export page's form) wins over ?format=.
        $format  = preg_replace('/[^a-z0-9_]/', '', strtolower((string) ($cfg['format'] ?? $_GET['format'] ?? ($allowed[0] ?? 'csv'))));

        // Permission gate — the Pages module decides which formats exist for this
        // table. Enforced here, never merely hidden in the toolbar.
        if ($allowed === [] || !in_array($format, $allowed, true)) {
            http_response_code(403);
            exit('This export format is not permitted.');
        }
        $writer = FormatRegistry::export($format);
        if ($writer === null) {
            http_response_code(400);
            exit('Unknown export format.');
        }

        $where  = (string) ($cfg['where'] ?? '');
        $params = (array) ($cfg['params'] ?? []);

        if (Events::fire("tables.$table.page.beforeExport", ['query' => null, 'format' => $format]) === false
            || Events::fire('dataops.export.before', ['table' => $table, 'format' => $format, 'where' => $where, 'params' => $params]) === false) {
            http_response_code(403);
            exit('Export is not allowed.');
        }

        // A scope of "current page" limits the slice; everything else exports the
        // whole matched set. Bounds are cast to int, never interpolated raw.
        $limitSql = '';
        if (($cfg['limit'] ?? 0) > 0) {
            $limitSql = ' LIMIT ' . (int) $cfg['limit'] . ' OFFSET ' . max(0, (int) ($cfg['offset'] ?? 0));
        }
        // Order by the real PK, not a hardcoded `id` — tables whose PK ≠ `id`
        // (e.g. std_id) threw on `ORDER BY \`id\``, which DB::Query swallowed to
        // an empty set → a silently EMPTY export file.
        $__pk = function_exists('pp_pk') ? preg_replace('/[^A-Za-z0-9_]/', '', (string) pp_pk($table)) : 'id';
        if ($__pk === '') $__pk = 'id';
        $rows = DB::FetchAll("SELECT * FROM `$table` $where ORDER BY `$__pk` ASC$limitSql", $params) ?: [];
        // Field Encryption: exports contain the real (decrypted) values.
        if (class_exists('\PpPhp\Core\Auth\FieldCrypto')) {
            $rows = \PpPhp\Core\Auth\FieldCrypto::decryptRows($table, $rows);
        }

        // Foreign keys export their display value, not the raw id.
        if (!empty($cfg['lookups']) && function_exists('pp_apply_lookups')) {
            pp_apply_lookups($rows, $cfg['lookups']);
        }

        $replaced = Events::fire('dataops.export.rows', ['table' => $table, 'format' => $format, 'rows' => $rows]);
        if (is_array($replaced)) $rows = $replaced;

        // Audit Trail: record who exported what (data leaving the app is auditable).
        if (class_exists('\PpPhp\Core\Auth\AuditLog')) {
            \PpPhp\Core\Auth\AuditLog::log('export', $table, null, null, null, strtoupper($format) . ' export, ' . count($rows) . ' row(s)');
        }

        $filename = (string) ($cfg['filename'] ?? ($table . '_' . date('Ymd_His')));
        $filename .= '.' . $writer::extension();

        header('Content-Type: ' . $writer::contentType());
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('X-Content-Type-Options: nosniff');

        $writer->write($rows, (array) $cfg['columns'], [
            'caption'        => (string) ($cfg['caption'] ?? $table),
            'brand'          => (string) ($cfg['brand'] ?? ($GLOBALS['PP_SHELL']['brand'] ?? '')),
            'table'          => $table,
            'generatedAt'    => date('M j, Y g:i a'),
            'includeHeaders' => ($cfg['includeHeaders'] ?? true) !== false,
        ]);

        Events::fire("tables.$table.page.afterExport", ['filename' => $filename, 'format' => $format]);
        Events::fire('dataops.export.after', ['table' => $table, 'format' => $format, 'filename' => $filename, 'count' => count($rows)]);
        exit;
    }
}
