<?php

/**
 * Utility helper methods for the plugin.
 *
 * @package Starter
 * @since 1.0.0
 */

namespace Storeplugin\PDFInvoice\Framework\Utils;

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

use Storeplugin\PDFInvoice\Framework\Forms\Setting;
use Storeplugin\PDFInvoice\Config;

/**
 * Utility helper methods.
 *
 * @since 1.0.0
 */
final class Utils
{
    /**
     * Check if WordPress debug mode is enabled.
     *
     * @return bool True if WP_DEBUG is enabled, false otherwise.
     * @since 1.0.0
     */
    public static function isDebugMode(): bool
    {
        return defined("WP_DEBUG") && WP_DEBUG;
    }

    /**
     * Log a debug message (only in debug mode).
     *
     * @param string $message The message to log.
     * @param string $prefix Optional prefix for the log message.
     * @return void
     * @since 1.0.0
     */
    public static function debugLog(string $message, string $prefix = ""): void
    {
        $prefix = $prefix ?: ucfirst(Config::prefix());
        if (self::isDebugMode() && defined("WP_DEBUG_LOG") && WP_DEBUG_LOG) {
            File::appendToDefaultLog(sprintf("%s: %s", $prefix, $message));
        }
    }

    /**
     * Log an error message.
     *
     * @param string          $message The error message.
     * @param \Throwable|null $exception Optional exception for context.
     * @return void
     * @since 1.0.0
     */
    public static function logError(
        string $message,
        ?\Throwable $exception = null,
    ): void {
        if ($exception) {
            $message .= sprintf(
                " - %s in %s:%d",
                $exception->getMessage(),
                $exception->getFile(),
                $exception->getLine(),
            );
        }

        if (defined("WP_DEBUG_LOG") && WP_DEBUG_LOG) {
            self::debugLog($message);
        }
    }

    /**
     * Get the current client IP address.
     *
     * @return string Client IP address or 'unknown' when unavailable.
     * @since 1.0.0
     */
    public static function getClientIP(): string
    {
        $clientIP = isset($_SERVER["HTTP_CLIENT_IP"])
            ? \sanitize_text_field(\wp_unslash($_SERVER["HTTP_CLIENT_IP"]))
            : "";
        $forwardedFor = isset($_SERVER["HTTP_X_FORWARDED_FOR"])
            ? \sanitize_text_field(
                \wp_unslash($_SERVER["HTTP_X_FORWARDED_FOR"]),
            )
            : "";
        $remoteAddr = isset($_SERVER["REMOTE_ADDR"])
            ? \sanitize_text_field(\wp_unslash($_SERVER["REMOTE_ADDR"]))
            : "";

        if ($clientIP !== "" && \filter_var($clientIP, FILTER_VALIDATE_IP)) {
            return $clientIP;
        }

        if ($forwardedFor !== "") {
            $ipList = \explode(",", $forwardedFor);
            foreach ($ipList as $possibleIP) {
                $possibleIP = \trim($possibleIP);
                if (\filter_var($possibleIP, FILTER_VALIDATE_IP)) {
                    return $possibleIP;
                }
            }
        }

        if (
            $remoteAddr !== "" &&
            \filter_var($remoteAddr, FILTER_VALIDATE_IP)
        ) {
            return $remoteAddr;
        }

        return "unknown";
    }

    /**
     * Sanitize a plugin option name.
     *
     * @param string $option The option name.
     * @return string Sanitized option name.
     * @since 1.0.0
     */
    public static function sanitizeOptionName(string $option): string
    {
        return Config::name() . "_" . sanitize_key($option);
    }

    /**
     * Cache for plugin options.
     *
     * @var array
     */
    private static $options_cache = [];

    /**
     * Get the option group name.
     *
     * @param string $group Option group key.
     * @return string
     */
    private static function getOptionGroup(string $group): string
    {
        $group = sanitize_key($group);
        if ($group === "") {
            throw new \InvalidArgumentException(
                "Option group cannot be empty. Pass an explicit settings optionName/group key.",
            );
        }

        return $group;
    }

    /**
     * Get a plugin option.
     *
     * @param string $option Option name within the centralized options array.
     * @param string $group Option group key.
     * @param mixed  $default Default value.
     * @return mixed Option value or default.
     * @since 1.0.0
     */
    public static function getOption(
        string $option,
        string $group,
        $default = false,
    ) {
        $options = self::getOptions($group);

        if (!array_key_exists($option, $options)) {
            return $default;
        }

        $value = $options[$option];
        if ($value === null) {
            return $default;
        }

        if ($value === "") {
            return $default;
        }

        return $value;
    }

    /**
     * Update a plugin option.
     *
     * @param string $option Option name within the centralized options array.
     * @param mixed  $value Option value.
     * @param string $group Option group key.
     * @return bool True on success, false on failure.
     * @since 1.0.0
     */
    public static function updateOption(
        string $option,
        $value,
        string $group,
    ): bool {
        $groupKey = self::getOptionGroup($group);
        $options = self::getCachedOptions($groupKey);
        $options[$option] = $value;

        $updated = update_option($groupKey, $options);

        if ($updated) {
            self::$options_cache[$groupKey] = $options;
        }

        return $updated;
    }

    /**
     * Delete a plugin option.
     *
     * @param string $option Option name within the centralized options array.
     * @param string $group Option group key.
     * @return bool True on success, false on failure.
     * @since 1.0.0
     */
    public static function deleteOption(string $option, string $group): bool
    {
        $groupKey = self::getOptionGroup($group);
        $options = self::getCachedOptions($groupKey);

        if (!array_key_exists($option, $options)) {
            return true;
        }

        unset($options[$option]);
        $deleted = update_option($groupKey, $options);

        if ($deleted) {
            self::$options_cache[$groupKey] = $options;
        }

        return $deleted;
    }

    /**
     * Get full options array for a specific group.
     *
     * @param string $group Option group key.
     * @return array<string, mixed>
     */
    public static function getOptions(string $group): array
    {
        $groupKey = self::getOptionGroup($group);
        return self::getCachedOptions($groupKey);
    }

    /**
     * Get all registered settings option groups from persistent registry.
     *
     * @return array<int, string>
     */
    public static function getOptionGroups(): array
    {
        $raw = get_option(Config::option("setting_groups"), []);
        $groups = [];

        if (is_array($raw)) {
            foreach ($raw as $group) {
                $groupKey = sanitize_key((string) $group);
                if ($groupKey !== "") {
                    $groups[] = $groupKey;
                }
            }
        }

        if (
            class_exists(Setting::class) &&
            method_exists(Setting::class, "getRegisteredOptionNames")
        ) {
            foreach ((array) Setting::getRegisteredOptionNames() as $group) {
                $groupKey = sanitize_key((string) $group);
                if ($groupKey !== "") {
                    $groups[] = $groupKey;
                }
            }
        }

        $groups = array_values(array_unique($groups));
        sort($groups, SORT_STRING);

        return $groups;
    }

    /**
     * Get options for all registered settings groups.
     *
     * @return array<string, array>
     */
    public static function getAllOptions(): array
    {
        $allOptions = [];
        foreach (self::getOptionGroups() as $group) {
            $allOptions[$group] = self::getOptions($group);
        }

        return $allOptions;
    }

    /**
     * Normalize a delimited string or array into a unique, lowercase list.
     *
     * @param mixed $value          String (comma/space/pipe separated) or array.
     * @param bool  $stripLeadingDot Remove leading dots (useful for file extensions).
     * @return array<int, string>
     */
    public static function normalizeList(
        $value,
        bool $stripLeadingDot = false,
    ): array {
        $items = [];

        if (is_string($value)) {
            $items = preg_split("/[\s,|]+/", $value) ?: [];
        } elseif (is_array($value)) {
            $items = $value;
        }

        $normalized = [];
        foreach ($items as $item) {
            if (!is_scalar($item)) {
                continue;
            }

            $item = strtolower(trim((string) $item));
            if ($item === "") {
                continue;
            }

            if ($stripLeadingDot) {
                $item = ltrim($item, ".");
            }

            if ($item === "") {
                continue;
            }

            $normalized[] = $item;
        }

        return array_values(array_unique($normalized));
    }

    /**
     * Normalize a byte-size value into an integer byte count.
     *
     * Accepts integers/floats, numeric strings, and human-readable values like "2 MB".
     *
     * @param mixed $value Raw byte-size value.
     * @return int
     */
    public static function normalizeByteSize($value): int
    {
        if (is_int($value)) {
            return max(0, $value);
        }

        if (is_float($value)) {
            return max(0, (int) round($value));
        }

        if (!is_scalar($value)) {
            return 0;
        }

        $value = trim((string) $value);
        if ($value === "") {
            return 0;
        }

        if (is_numeric($value)) {
            return max(0, (int) round((float) $value));
        }

        if (function_exists("wp_convert_hr_to_bytes")) {
            return max(0, (int) wp_convert_hr_to_bytes($value));
        }

        if (
            !preg_match('/^\s*([\d.]+)\s*([kmgtp]?b?)\s*$/i', $value, $matches)
        ) {
            return 0;
        }

        $bytes = (float) $matches[1];
        $unit = strtoupper($matches[2]);
        $units = [
            "" => 1,
            "B" => 1,
            "K" => 1024,
            "KB" => 1024,
            "M" => 1024 ** 2,
            "MB" => 1024 ** 2,
            "G" => 1024 ** 3,
            "GB" => 1024 ** 3,
            "T" => 1024 ** 4,
            "TB" => 1024 ** 4,
            "P" => 1024 ** 5,
            "PB" => 1024 ** 5,
        ];

        if (!isset($units[$unit])) {
            return 0;
        }

        return max(0, (int) round($bytes * $units[$unit]));
    }

    /**
     * Format a byte count into a readable label.
     *
     * @param int $bytes Byte count.
     * @return string
     */
    public static function formatByteSize(int $bytes): string
    {
        $bytes = max(0, $bytes);

        if (function_exists("size_format")) {
            return (string) size_format($bytes);
        }

        return sprintf("%d bytes", $bytes);
    }

    /**
     * Normalize option payload from DB into a stable array shape.
     *
     * @param mixed $options Raw option value.
     * @return array<string, mixed>
     */
    private static function normalizeOptionsArray($options): array
    {
        return is_array($options) ? $options : [];
    }

    /**
     * Get cached options for a sanitized group key.
     *
     * @param string $groupKey Sanitized option group key.
     * @return array<string, mixed>
     */
    private static function getCachedOptions(string $groupKey): array
    {
        if (!array_key_exists($groupKey, self::$options_cache)) {
            $raw = get_option($groupKey, []);
            self::$options_cache[$groupKey] = self::normalizeOptionsArray($raw);
        }

        return self::$options_cache[$groupKey];
    }
}
