<?php

declare(strict_types=1);

namespace PpPhp\Core\DataOps;

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

/**
 * The import pipeline: read → map → validate → commit.
 *
 * Pure logic, no HTML — ImportPage renders, this decides. Keeping them apart is
 * what lets new sources/formats/modes be added without touching either the view
 * or the build.
 *
 * Events (Events module):
 *   dataops.import.beforeRead  ['table','path','reader','opts'] → false cancels
 *   dataops.import.afterRead   ['table','header','rows'] → return [header,rows] replaces
 *   dataops.import.row         ['table','row','index'] → false skips, array replaces
 *   dataops.import.beforeCommit['table','rows','mode'] → false cancels
 *   dataops.import.afterCommit ['table','result']
 * Legacy tables.<t>.page.beforeImport / afterImport still fire.
 */
final class ImportRunner
{
    /** Read a stored file with the reader matching $readerId (or the extension). */
    public static function read(string $table, string $path, string $readerId, array $opts): array
    {
        $reader = FormatRegistry::reader($readerId)
            ?? FormatRegistry::readerForExtension(pathinfo($path, PATHINFO_EXTENSION))
            ?? FormatRegistry::reader('csv');

        if (Events::fire('dataops.import.beforeRead', ['table' => $table, 'path' => $path, 'reader' => $readerId, 'opts' => $opts]) === false) {
            return [[], []];
        }
        [$header, $rows] = $reader->read($path, $opts);

        $r = Events::fire('dataops.import.afterRead', ['table' => $table, 'header' => $header, 'rows' => $rows]);
        if (is_array($r) && count($r) === 2 && is_array($r[0]) && is_array($r[1])) {
            [$header, $rows] = $r;
        }
        return [$header, $rows];
    }

    /** Best-guess column index for a field, by header text / field name. */
    public static function guess(string $field, string $label, array $header): string
    {
        $norm = static fn($s) => preg_replace('/[^a-z0-9]/', '', strtolower((string) $s));
        $want = [$norm($field), $norm($label)];
        foreach ($header as $i => $h) {
            if (in_array($norm($h), $want, true)) return (string) $i;
        }
        return '';
    }

    /**
     * Turn raw rows + a column map into field=>value records, collecting errors.
     *
     * @return array{0: array<int,array<string,mixed>>, 1: list<string>}
     */
    public static function prepare(string $table, array $rows, array $map, array $names): array
    {
        $prepared = []; $errors = [];
        foreach ($rows as $i => $r) {
            $data = [];
            foreach ($map as $field => $idx) {
                if ($idx === '' || $idx === null || !in_array($field, $names, true)) continue;
                $data[$field] = $r[(int) $idx] ?? '';
            }
            if ($data === []) { $errors[] = 'Row ' . ($i + 1) . ': no mapped values'; continue; }

            $hook = Events::fire('dataops.import.row', ['table' => $table, 'row' => $data, 'index' => $i]);
            if ($hook === false) continue;                 // business rule skipped it
            if (is_array($hook) && $hook !== []) $data = $hook;

            $prepared[$i] = $data;
        }
        return [$prepared, $errors];
    }

    /**
     * Write the prepared rows.
     *
     * @param string $mode  insert | skip | update
     * @return array{ok:int,updated:int,skipped:int,fail:int,errs:list<string>,aborted:bool}
     */
    public static function commit(string $table, array $prepared, string $mode, string $matchField, array $errors = []): array
    {
        $result = ['ok' => 0, 'updated' => 0, 'skipped' => 0, 'fail' => 0, 'errs' => $errors, 'aborted' => false];

        if (Events::fire('dataops.import.beforeCommit', ['table' => $table, 'rows' => $prepared, 'mode' => $mode]) === false) {
            $result['aborted'] = true;
            $result['errs'][] = 'Import was cancelled by a business rule.';
            return $result;
        }

        // Size allowance, checked BEFORE writing anything — a half-applied import
        // that stops mid-way is worse than one that never starts.
        //
        // Inert unless this app is running on the PPPHP platform: the check is
        // skipped entirely when the quota class is absent (generated apps deployed
        // to a customer's own hosting do not ship it), and it fails open if the
        // limit cannot be read. Our development allowance must never police a
        // database on somebody else's server.
        if (class_exists(\PpPhp\Core\Databases\DatabaseQuota::class)) {
            $q = \PpPhp\Core\Databases\DatabaseQuota::guardCurrentConnection(
                \PpPhp\Core\Databases\DatabaseQuota::estimateRows($prepared)
            );
            if (!$q['allowed']) {
                $result['aborted'] = true;
                $result['errs'][] = $q['message'];
                return $result;
            }
        }

        foreach ($prepared as $i => $data) {
            try {
                $existing = null;
                if ($mode !== 'insert' && $matchField !== '' && (string) ($data[$matchField] ?? '') !== '') {
                    $hit = DB::FetchAll("SELECT `id` FROM `$table` WHERE `$matchField` = ? LIMIT 1", [$data[$matchField]]);
                    $existing = $hit[0]['id'] ?? null;
                }
                if ($existing !== null && $mode === 'skip') { $result['skipped']++; continue; }
                if ($existing !== null && $mode === 'update') {
                    DB::Update($table, $data, ['id' => $existing]);
                    $result['updated']++;
                    Events::fire("tables.$table.page.afterEditSave", ['record' => $data, 'id' => $existing, 'source' => 'import']);
                    continue;
                }
                $newId = DB::Insert($table, $data);
                if ($newId) {
                    $result['ok']++;
                    Events::fire("tables.$table.page.afterAddSave", ['record' => $data, 'id' => $newId, 'source' => 'import']);
                } else {
                    $result['fail']++;
                    $result['errs'][] = 'Row ' . ($i + 1) . ': insert failed';
                }
            } catch (\Throwable $e) {
                $result['fail']++;
                $result['errs'][] = 'Row ' . ($i + 1) . ': ' . $e->getMessage();
            }
        }

        Events::fire("tables.$table.page.afterImport", ['imported' => $result['ok'], 'updated' => $result['updated'], 'skipped' => $result['skipped'], 'failed' => $result['fail']]);
        Events::fire('dataops.import.afterCommit', ['table' => $table, 'result' => $result]);
        return $result;
    }
}
