<?php

defined("ABSPATH") || exit();

/**
 * Bulk import of externally generated codes from a fixed-format CSV:
 *
 *   code,sku,status,activated_at,activated_email
 *
 * - code            required; 4-64 chars of A-Z 0-9 dash (uppercased on import)
 * - sku             required; must resolve to an existing product/variation
 * - status          unused|activated|revoked|expired; blank = unused
 * - activated_at    optional datetime; only used when status = activated
 * - activated_email optional; matched to a WP user, NULL when no match
 *
 * The file is streamed (never loaded whole), validated fully before any
 * write, then processed in Action Scheduler chunks with a persisted byte
 * offset so a 300k-row import survives timeouts and restarts. Rows are
 * written with multi-row INSERT IGNORE; the unique index on `code` makes
 * re-runs and duplicate rows harmless (they count as skipped). Every row
 * is stamped with an import batch id so an import can be deleted whole.
 */
class Chit_Import
{
    public const MAX_CODE_LENGTH = 64;
    public const STATUSES = ["unused", "activated", "revoked", "expired"];
    public const HEADERS = ["code", "sku", "status", "activated_at", "activated_email"];
    public const ROWS_PER_CHUNK = 5000;
    public const ROWS_PER_INSERT = 500;
    public const MAX_REPORTED = 20;
    public const STATE_OPTION = "chit_import_state";
    public const HISTORY_OPTION = "chit_import_history";
    public const HOOK = "chit_process_import_chunk";

    public static function init(): void
    {
        // Registered unconditionally: Action Scheduler runs chunks outside
        // of admin requests (cron, frontend queue runners, WP-CLI).
        add_action(self::HOOK, [self::class, "process_chunk"]);
    }

    // ---------------------------------------------------------------- state

    public static function get_state(): ?array
    {
        $state = get_option(self::STATE_OPTION);
        return is_array($state) ? $state : null;
    }

    private static function save_state(array $state): void
    {
        update_option(self::STATE_OPTION, $state, false);
    }

    private static function clear_state(): void
    {
        delete_option(self::STATE_OPTION);
    }

    public static function get_history(): array
    {
        $history = get_option(self::HISTORY_OPTION);
        return is_array($history) ? $history : [];
    }

    private static function push_history(array $entry): void
    {
        $history = self::get_history();
        array_unshift($history, $entry);
        update_option(self::HISTORY_OPTION, array_slice($history, 0, 10), false);
    }

    public static function remove_history_entry(int $batch_id): void
    {
        $history = array_values(
            array_filter(self::get_history(), function ($entry) use ($batch_id) {
                return (int) $entry["batch_id"] !== $batch_id;
            })
        );
        update_option(self::HISTORY_OPTION, $history, false);
    }

    // ---------------------------------------------------------------- files

    /**
     * Private-ish holding directory for uploaded import files.
     *
     * Takes the uploads basedir as an argument because one caller is an
     * upload_dir filter callback — calling wp_upload_dir() from there
     * re-applies the filter and recurses forever.
     */
    public static function ensure_import_dir(string $uploads_basedir): string
    {
        $dir = trailingslashit($uploads_basedir) . "chit-imports";
        if (!is_dir($dir)) {
            wp_mkdir_p($dir);
            @file_put_contents($dir . "/index.html", "");
            @file_put_contents($dir . "/.htaccess", "deny from all\n");
        }
        return $dir;
    }

    // ----------------------------------------------------------- validation

    /**
     * Full streaming pass over the file. Nothing is written; the goal is
     * that a malformed file fails here, not at row 280,000 of the import.
     */
    public static function validate(string $path): array
    {
        $summary = [
            "ok" => false,
            "total_rows" => 0,
            "statuses" => array_fill_keys(self::STATUSES, 0),
            "unique_skus" => 0,
            "error_count" => 0,
            "errors" => [],
            "warning_count" => 0,
            "warnings" => [],
        ];

        $handle = fopen($path, "r");
        if (!$handle) {
            $summary["errors"][] = [0, __("Could not open the file.", "chit-download-codes-for-woocommerce")];
            $summary["error_count"] = 1;
            return $summary;
        }

        $error = function (int $line, string $message) use (&$summary) {
            $summary["error_count"]++;
            if (count($summary["errors"]) < self::MAX_REPORTED) {
                $summary["errors"][] = [$line, $message];
            }
        };
        $warning = function (int $line, string $message) use (&$summary) {
            $summary["warning_count"]++;
            if (count($summary["warnings"]) < self::MAX_REPORTED) {
                $summary["warnings"][] = [$line, $message];
            }
        };

        $header = fgetcsv($handle, 0, ",", "\"", "");
        if ($header && isset($header[0])) {
            // Strip a UTF-8 BOM (Excel exports lead with one).
            $header[0] = preg_replace('/^\xEF\xBB\xBF/', "", $header[0]);
        }
        $header = array_map(function ($h) {
            return strtolower(trim((string) $h));
        }, $header ?: []);
        if ($header !== self::HEADERS) {
            $error(
                1,
                sprintf(
                    /* translators: %s: expected CSV header line */
                    __('The header row must be exactly: %s', "chit-download-codes-for-woocommerce"),
                    implode(",", self::HEADERS)
                )
            );
            fclose($handle);
            return $summary;
        }

        $skus = [];
        $line = 1;
        while (($row = fgetcsv($handle, 0, ",", "\"", "")) !== false) {
            $line++;
            if ($row === [null] || $row === [""]) {
                continue; // blank line
            }
            $parsed = self::parse_row($row);
            if (is_wp_error($parsed)) {
                $error($line, $parsed->get_error_message());
                continue;
            }

            $summary["total_rows"]++;
            $summary["statuses"][$parsed["status"]]++;

            if (!isset($skus[$parsed["sku"]])) {
                $skus[$parsed["sku"]] = $line;
            }

            if ($parsed["status"] === "activated" && $parsed["activated_at"] === null) {
                $warning($line, __("Status is activated but activated_at is blank; the date will be empty.", "chit-download-codes-for-woocommerce"));
            }
            if ($parsed["ignored_fields"]) {
                $warning($line, __("activated_at/activated_email are ignored unless status is activated.", "chit-download-codes-for-woocommerce"));
            }
            if ($parsed["activated_email"] !== "" && !is_email($parsed["activated_email"])) {
                $warning($line, __("activated_email is not a valid email address; it will not be matched to a user.", "chit-download-codes-for-woocommerce"));
            }
        }
        fclose($handle);

        $summary["unique_skus"] = count($skus);

        if ($summary["total_rows"] === 0 && $summary["error_count"] === 0) {
            $error(1, __("The file contains no data rows.", "chit-download-codes-for-woocommerce"));
        }

        // Resolve every SKU once, in chunks, against the indexed lookup table.
        if ($skus) {
            $resolved = self::resolve_skus(array_keys($skus));
            foreach ($skus as $sku => $first_line) {
                if (!isset($resolved[$sku])) {
                    $error(
                        $first_line,
                        sprintf(
                            /* translators: %s: product SKU from the CSV */
                            __('SKU "%s" does not match any product.', "chit-download-codes-for-woocommerce"),
                            $sku
                        )
                    );
                }
            }
        }

        $summary["ok"] = $summary["error_count"] === 0 && $summary["total_rows"] > 0;
        return $summary;
    }

    /**
     * Normalize one CSV row. Returns WP_Error for rows that must block the
     * import; soft issues are flagged for the caller to count as warnings.
     *
     * @return array|WP_Error
     */
    private static function parse_row(array $row)
    {
        if (count($row) > 5) {
            return new WP_Error("chit_row", __("Row has more than 5 columns.", "chit-download-codes-for-woocommerce"));
        }
        $row = array_map(function ($v) {
            return trim((string) $v);
        }, array_pad($row, 5, ""));
        [$code, $sku, $status, $activated_at, $activated_email] = $row;

        $code = strtoupper($code);
        if (!preg_match('/^[A-Z0-9][A-Z0-9-]{3,' . (self::MAX_CODE_LENGTH - 1) . '}$/', $code)) {
            return new WP_Error(
                "chit_row",
                sprintf(
                    /* translators: %d: maximum code length */
                    __("Invalid code: 4-%d characters, letters/numbers/dashes only.", "chit-download-codes-for-woocommerce"),
                    self::MAX_CODE_LENGTH
                )
            );
        }

        if ($sku === "") {
            return new WP_Error("chit_row", __("Missing SKU.", "chit-download-codes-for-woocommerce"));
        }

        $status = $status === "" ? "unused" : strtolower($status);
        if (!in_array($status, self::STATUSES, true)) {
            return new WP_Error(
                "chit_row",
                sprintf(
                    /* translators: 1: status value from the CSV, 2: allowed status list */
                    __('Unknown status "%1$s" (allowed: %2$s).', "chit-download-codes-for-woocommerce"),
                    $status,
                    implode(", ", self::STATUSES)
                )
            );
        }

        $ignored = false;
        if ($status !== "activated") {
            $ignored = $activated_at !== "" || $activated_email !== "";
            $activated_at = "";
            $activated_email = "";
        }

        $timestamp = null;
        if ($activated_at !== "") {
            $timestamp = strtotime($activated_at);
            if ($timestamp === false) {
                return new WP_Error(
                    "chit_row",
                    sprintf(
                        /* translators: %s: date value from the CSV */
                        __('Unparseable activated_at date "%s".', "chit-download-codes-for-woocommerce"),
                        $activated_at
                    )
                );
            }
        }

        return [
            "code" => $code,
            "sku" => $sku,
            "status" => $status,
            "activated_at" => $timestamp ? gmdate("Y-m-d H:i:s", $timestamp) : null,
            "activated_email" => $activated_email,
            "ignored_fields" => $ignored,
        ];
    }

    /**
     * sku => product_id via wc_product_meta_lookup (indexed, covers
     * variations), chunked so 10k unique SKUs doesn't build a mega-query.
     */
    private static function resolve_skus(array $skus): array
    {
        global $wpdb;
        $map = [];
        foreach (array_chunk($skus, 500) as $chunk) {
            $placeholders = implode(",", array_fill(0, count($chunk), "%s"));
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table from $wpdb->prefix, IN list from prepare() placeholders
            $rows = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT product_id, sku FROM {$wpdb->prefix}wc_product_meta_lookup WHERE sku IN ($placeholders)",
                    ...$chunk
                ),
                ARRAY_A
            );
            foreach ($rows as $row) {
                $map[$row["sku"]] = (int) $row["product_id"];
            }
        }
        return $map;
    }

    // -------------------------------------------------------------- running

    /**
     * Create the batch + state and queue the first chunk.
     */
    public static function start(string $path, string $filename, int $total_rows)
    {
        $state = self::get_state();
        if ($state && $state["status"] === "running") {
            return new WP_Error("chit_import_busy", __("Another import is already running.", "chit-download-codes-for-woocommerce"));
        }
        if (!function_exists("as_enqueue_async_action")) {
            return new WP_Error("chit_import_as", __("Action Scheduler is unavailable — is WooCommerce active?", "chit-download-codes-for-woocommerce"));
        }

        $batch_id = Chit_Batches::create_import_batch(
            sprintf(
                /* translators: %s: uploaded CSV filename */
                __("Import: %s", "chit-download-codes-for-woocommerce"),
                $filename
            )
        );

        self::save_state([
            "status" => "running",
            "batch_id" => $batch_id,
            "path" => $path,
            "filename" => $filename,
            "offset" => 0,
            "total_rows" => $total_rows,
            "imported" => 0,
            "skipped" => 0,
            "unmatched_emails" => 0,
            "error" => "",
            "started_at" => current_time("mysql"),
        ]);

        as_enqueue_async_action(self::HOOK, [], "chit");
        return $batch_id;
    }

    public static function process_chunk(): void
    {
        $state = self::get_state();
        if (!$state || $state["status"] !== "running") {
            return;
        }

        try {
            $handle = fopen($state["path"], "r");
            if (!$handle) {
                throw new Exception(
                    sprintf(
                        /* translators: %s: file path */
                        __("Import file disappeared: %s", "chit-download-codes-for-woocommerce"),
                        $state["path"]
                    )
                );
            }

            if ($state["offset"] === 0) {
                fgetcsv($handle, 0, ",", "\"", ""); // header (validated earlier)
            } else {
                fseek($handle, $state["offset"]);
            }

            $rows = [];
            $read = 0;
            $done = true;
            while (($row = fgetcsv($handle, 0, ",", "\"", "")) !== false) {
                if ($row === [null] || $row === [""]) {
                    continue;
                }
                $parsed = self::parse_row($row);
                if (is_wp_error($parsed)) {
                    // Validation guaranteed clean rows; a mid-import edit of
                    // the file is the only way here. Count it, keep going.
                    $state["skipped"]++;
                    continue;
                }
                $rows[] = $parsed;
                if (++$read >= self::ROWS_PER_CHUNK) {
                    $done = false;
                    break;
                }
            }
            $state["offset"] = ftell($handle);
            fclose($handle);

            foreach (array_chunk($rows, self::ROWS_PER_INSERT) as $insert_rows) {
                $result = self::insert_rows($insert_rows, (int) $state["batch_id"]);
                $state["imported"] += $result["inserted"];
                $state["skipped"] += $result["skipped"];
                $state["unmatched_emails"] += $result["unmatched_emails"];
            }

            if ($done) {
                self::finish($state);
            } else {
                self::save_state($state);
                as_enqueue_async_action(self::HOOK, [], "chit");
            }
        } catch (Throwable $e) {
            $state["status"] = "failed";
            $state["error"] = $e->getMessage();
            self::save_state($state);
        }
    }

    private static function finish(array $state): void
    {
        Chit_Batches::set_quantity((int) $state["batch_id"], (int) $state["imported"]);

        @unlink($state["path"]);

        self::push_history([
            "batch_id" => (int) $state["batch_id"],
            "filename" => $state["filename"],
            "imported" => (int) $state["imported"],
            "skipped" => (int) $state["skipped"],
            "unmatched_emails" => (int) $state["unmatched_emails"],
            "status" => "done",
            "finished_at" => current_time("mysql"),
        ]);
        self::clear_state();
    }

    /**
     * Re-queue a failed import; the byte offset picks up where it stopped.
     */
    public static function resume(): bool
    {
        $state = self::get_state();
        if (!$state || $state["status"] !== "failed" || !function_exists("as_enqueue_async_action")) {
            return false;
        }
        $state["status"] = "running";
        $state["error"] = "";
        self::save_state($state);
        as_enqueue_async_action(self::HOOK, [], "chit");
        return true;
    }

    /**
     * Abandon a running/failed import. Rows already written stay (they're
     * deletable via the batch); the uploaded file is removed.
     */
    public static function cancel(): void
    {
        $state = self::get_state();
        if (!$state) {
            return;
        }
        if (file_exists($state["path"])) {
            @unlink($state["path"]);
        }
        Chit_Batches::set_quantity((int) $state["batch_id"], (int) $state["imported"]);
        self::push_history([
            "batch_id" => (int) $state["batch_id"],
            "filename" => $state["filename"],
            "imported" => (int) $state["imported"],
            "skipped" => (int) $state["skipped"],
            "unmatched_emails" => (int) $state["unmatched_emails"],
            "status" => "cancelled",
            "finished_at" => current_time("mysql"),
        ]);
        self::clear_state();
    }

    /**
     * Roll an import back: delete every code stamped with its batch id.
     */
    public static function delete_import(int $batch_id): int
    {
        $deleted = Chit_Batches::delete_with_codes($batch_id);
        self::remove_history_entry($batch_id);
        return $deleted;
    }

    // -------------------------------------------------------------- writing

    private static function insert_rows(array $rows, int $batch_id): array
    {
        global $wpdb;
        static $sku_cache = [];
        static $email_cache = [];

        $missing = [];
        foreach ($rows as $row) {
            if (!array_key_exists($row["sku"], $sku_cache)) {
                $missing[$row["sku"]] = true;
            }
        }
        if ($missing) {
            $sku_cache += self::resolve_skus(array_keys($missing));
        }

        $created_at = current_time("mysql");
        $placeholders = [];
        $values = [];
        $unmatched_emails = 0;
        $attempted = 0;

        foreach ($rows as $row) {
            if (empty($sku_cache[$row["sku"]])) {
                // Product deleted between validation and import.
                continue;
            }

            $user_id = null;
            if ($row["activated_email"] !== "" && is_email($row["activated_email"])) {
                $key = strtolower($row["activated_email"]);
                if (!array_key_exists($key, $email_cache)) {
                    $user = get_user_by("email", $key);
                    $email_cache[$key] = $user ? (int) $user->ID : null;
                }
                $user_id = $email_cache[$key];
                if ($user_id === null) {
                    $unmatched_emails++;
                }
            }

            $attempted++;
            $placeholders[] = sprintf(
                "(%%d, %%d, %%s, %%s, %s, %s, %%s)",
                $row["activated_at"] === null ? "NULL" : "%s",
                $user_id === null ? "NULL" : "%d"
            );
            $values[] = $sku_cache[$row["sku"]];
            $values[] = $batch_id;
            $values[] = $row["code"];
            $values[] = $row["status"];
            if ($row["activated_at"] !== null) {
                $values[] = $row["activated_at"];
            }
            if ($user_id !== null) {
                $values[] = $user_id;
            }
            $values[] = $created_at;
        }

        if (!$placeholders) {
            return ["inserted" => 0, "skipped" => count($rows), "unmatched_emails" => 0];
        }

        $table = $wpdb->prefix . "chit_codes";
        $sql =
            "INSERT IGNORE INTO $table " .
            "(product_id, batch_id, code, status, activated_at, activated_by, created_at) VALUES " .
            implode(",", $placeholders);
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from $wpdb->prefix and prepare() placeholders
        $inserted = $wpdb->query($wpdb->prepare($sql, ...$values));
        if ($inserted === false) {
            throw new Exception($wpdb->last_error);
        }

        return [
            "inserted" => (int) $inserted,
            "skipped" => count($rows) - (int) $inserted,
            "unmatched_emails" => $unmatched_emails,
        ];
    }
}
