<?php

/**
 * PHP Essentials — Zero-Dependency Utility Toolkit
 *
 * A comprehensive single-file library with 80+ static helpers for everyday PHP tasks.
 * Requires PHP 8.0+. No external dependencies.
 *
 * Classes:
 *   Str      — String manipulation
 *   Arr      — Array utilities
 *   Date     — Date/time helpers
 *   Validate — Input validation
 *   Secure   — Security & hashing
 *   Http     — HTTP request helpers
 *   File     — Filesystem operations
 *
 * @version 1.0.0
 * @author  Ahmed Habib
 * @license MIT
 */

namespace PhpEssentials;

// ─── String Helpers ──────────────────────────────────────────────────────────

class Str
{
    /**
     * Convert a string to a URL-friendly slug.
     */
    public static function slug(string $text, string $separator = '-'): string
    {
        $text = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $text) ?: mb_strtolower($text);
        $text = preg_replace('/[^a-z0-9\s-]/', '', $text);
        $text = preg_replace('/[\s-]+/', $separator, trim($text));
        return trim($text, $separator);
    }

    /**
     * Convert a string to camelCase.
     */
    public static function camel(string $text): string
    {
        return lcfirst(self::pascal($text));
    }

    /**
     * Convert a string to PascalCase.
     */
    public static function pascal(string $text): string
    {
        $text = preg_replace('/[^a-zA-Z0-9]/', ' ', $text);
        return str_replace(' ', '', ucwords(strtolower($text)));
    }

    /**
     * Convert a string to snake_case.
     */
    public static function snake(string $text): string
    {
        $text = preg_replace('/([a-z])([A-Z])/', '$1_$2', $text);
        $text = preg_replace('/[^a-zA-Z0-9]+/', '_', $text);
        return strtolower(trim($text, '_'));
    }

    /**
     * Convert a string to kebab-case.
     */
    public static function kebab(string $text): string
    {
        return str_replace('_', '-', self::snake($text));
    }

    /**
     * Convert a string to Title Case.
     */
    public static function title(string $text): string
    {
        return mb_convert_case($text, MB_CASE_TITLE, 'UTF-8');
    }

    /**
     * Truncate a string to a given length, appending a suffix.
     */
    public static function truncate(string $text, int $length = 100, string $suffix = '...'): string
    {
        if (mb_strlen($text) <= $length) {
            return $text;
        }
        return rtrim(mb_substr($text, 0, $length - mb_strlen($suffix))) . $suffix;
    }

    /**
     * Truncate to the nearest word boundary.
     */
    public static function truncateWords(string $text, int $words = 20, string $suffix = '...'): string
    {
        $arr = explode(' ', $text);
        if (count($arr) <= $words) {
            return $text;
        }
        return implode(' ', array_slice($arr, 0, $words)) . $suffix;
    }

    /**
     * Check if a string contains a substring (case-insensitive option).
     */
    public static function contains(string $haystack, string $needle, bool $ignoreCase = false): bool
    {
        if ($ignoreCase) {
            return mb_stripos($haystack, $needle) !== false;
        }
        return mb_strpos($haystack, $needle) !== false;
    }

    /**
     * Check if a string starts with a given prefix.
     */
    public static function startsWith(string $haystack, string $needle): bool
    {
        return str_starts_with($haystack, $needle);
    }

    /**
     * Check if a string ends with a given suffix.
     */
    public static function endsWith(string $haystack, string $needle): bool
    {
        return str_ends_with($haystack, $needle);
    }

    /**
     * Extract text between two delimiters.
     */
    public static function between(string $text, string $start, string $end): string
    {
        $startPos = mb_strpos($text, $start);
        if ($startPos === false) return '';
        $startPos += mb_strlen($start);
        $endPos = mb_strpos($text, $end, $startPos);
        if ($endPos === false) return '';
        return mb_substr($text, $startPos, $endPos - $startPos);
    }

    /**
     * Mask part of a string (e.g. email, phone).
     */
    public static function mask(string $text, string $char = '*', int $visibleStart = 2, int $visibleEnd = 2): string
    {
        $len = mb_strlen($text);
        if ($len <= $visibleStart + $visibleEnd) {
            return $text;
        }
        $start = mb_substr($text, 0, $visibleStart);
        $end = mb_substr($text, -$visibleEnd);
        $masked = str_repeat($char, $len - $visibleStart - $visibleEnd);
        return $start . $masked . $end;
    }

    /**
     * Count the number of words in a string.
     */
    public static function wordCount(string $text): int
    {
        $text = trim($text);
        if ($text === '') return 0;
        return count(preg_split('/\s+/', $text));
    }

    /**
     * Estimate reading time in minutes.
     */
    public static function readingTime(string $text, int $wpm = 200): int
    {
        return (int) max(1, ceil(self::wordCount($text) / $wpm));
    }

    /**
     * Generate a random alphanumeric string.
     */
    public static function random(int $length = 16): string
    {
        $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
        $result = '';
        $max = strlen($chars) - 1;
        for ($i = 0; $i < $length; $i++) {
            $result .= $chars[random_int(0, $max)];
        }
        return $result;
    }

    /**
     * Convert plain URLs in text to clickable HTML links.
     */
    public static function linkify(string $text): string
    {
        return preg_replace(
            '/(https?:\/\/[^\s<]+)/i',
            '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
            htmlspecialchars($text, ENT_QUOTES, 'UTF-8')
        );
    }

    /**
     * Strip all HTML tags and decode entities.
     */
    public static function plainText(string $html): string
    {
        $text = strip_tags($html);
        return html_entity_decode($text, ENT_QUOTES, 'UTF-8');
    }

    /**
     * Convert a string to an excerpt (strip HTML, truncate to words).
     */
    public static function excerpt(string $html, int $words = 30): string
    {
        return self::truncateWords(self::plainText($html), $words);
    }

    /**
     * Pad a string on both sides to center it.
     */
    public static function center(string $text, int $width, string $pad = ' '): string
    {
        return str_pad($text, $width, $pad, STR_PAD_BOTH);
    }

    /**
     * Convert newlines to <br> tags.
     */
    public static function nl2br(string $text): string
    {
        return nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'));
    }
}

// ─── Array Helpers ───────────────────────────────────────────────────────────

class Arr
{
    /**
     * Get a value from an array using dot notation.
     */
    public static function get(array $array, string $key, mixed $default = null): mixed
    {
        if (array_key_exists($key, $array)) {
            return $array[$key];
        }
        foreach (explode('.', $key) as $segment) {
            if (!is_array($array) || !array_key_exists($segment, $array)) {
                return $default;
            }
            $array = $array[$segment];
        }
        return $array;
    }

    /**
     * Set a value in an array using dot notation.
     */
    public static function set(array &$array, string $key, mixed $value): void
    {
        $keys = explode('.', $key);
        $current = &$array;
        foreach ($keys as $k) {
            if (!isset($current[$k]) || !is_array($current[$k])) {
                $current[$k] = [];
            }
            $current = &$current[$k];
        }
        $current = $value;
    }

    /**
     * Check if a key exists using dot notation.
     */
    public static function has(array $array, string $key): bool
    {
        if (array_key_exists($key, $array)) return true;
        foreach (explode('.', $key) as $segment) {
            if (!is_array($array) || !array_key_exists($segment, $array)) {
                return false;
            }
            $array = $array[$segment];
        }
        return true;
    }

    /**
     * Remove a key using dot notation.
     */
    public static function forget(array &$array, string $key): void
    {
        $keys = explode('.', $key);
        $last = array_pop($keys);
        $current = &$array;
        foreach ($keys as $k) {
            if (!is_array($current) || !isset($current[$k])) return;
            $current = &$current[$k];
        }
        unset($current[$last]);
    }

    /**
     * Flatten a multi-dimensional array.
     */
    public static function flatten(array $array, int $depth = PHP_INT_MAX): array
    {
        $result = [];
        foreach ($array as $item) {
            if (is_array($item) && $depth > 0) {
                $result = array_merge($result, self::flatten($item, $depth - 1));
            } else {
                $result[] = $item;
            }
        }
        return $result;
    }

    /**
     * Pluck a single column from an array of arrays/objects.
     */
    public static function pluck(array $array, string $key, ?string $indexBy = null): array
    {
        $result = [];
        foreach ($array as $item) {
            $value = is_object($item) ? ($item->$key ?? null) : ($item[$key] ?? null);
            if ($indexBy !== null) {
                $index = is_object($item) ? ($item->$indexBy ?? null) : ($item[$indexBy] ?? null);
                $result[$index] = $value;
            } else {
                $result[] = $value;
            }
        }
        return $result;
    }

    /**
     * Group an array of arrays/objects by a key.
     */
    public static function groupBy(array $array, string $key): array
    {
        $result = [];
        foreach ($array as $item) {
            $groupKey = is_object($item) ? ($item->$key ?? '') : ($item[$key] ?? '');
            $result[$groupKey][] = $item;
        }
        return $result;
    }

    /**
     * Return only the specified keys from an array.
     */
    public static function only(array $array, array $keys): array
    {
        return array_intersect_key($array, array_flip($keys));
    }

    /**
     * Return all keys except the specified ones.
     */
    public static function except(array $array, array $keys): array
    {
        return array_diff_key($array, array_flip($keys));
    }

    /**
     * Return the first element matching a callback, or a default.
     */
    public static function first(array $array, ?callable $callback = null, mixed $default = null): mixed
    {
        if ($callback === null) {
            return empty($array) ? $default : reset($array);
        }
        foreach ($array as $key => $value) {
            if ($callback($value, $key)) return $value;
        }
        return $default;
    }

    /**
     * Return the last element matching a callback, or a default.
     */
    public static function last(array $array, ?callable $callback = null, mixed $default = null): mixed
    {
        return self::first(array_reverse($array, true), $callback, $default);
    }

    /**
     * Filter an array where a key matches a value.
     */
    public static function where(array $array, string $key, mixed $value): array
    {
        return array_filter($array, function ($item) use ($key, $value) {
            $itemValue = is_object($item) ? ($item->$key ?? null) : ($item[$key] ?? null);
            return $itemValue === $value;
        });
    }

    /**
     * Sort an array of arrays/objects by a key.
     */
    public static function sortBy(array $array, string $key, string $direction = 'asc'): array
    {
        usort($array, function ($a, $b) use ($key, $direction) {
            $aVal = is_object($a) ? ($a->$key ?? null) : ($a[$key] ?? null);
            $bVal = is_object($b) ? ($b->$key ?? null) : ($b[$key] ?? null);
            $cmp = $aVal <=> $bVal;
            return $direction === 'desc' ? -$cmp : $cmp;
        });
        return $array;
    }

    /**
     * Return unique values from an array of arrays/objects by a key.
     */
    public static function uniqueBy(array $array, string $key): array
    {
        $seen = [];
        $result = [];
        foreach ($array as $item) {
            $val = is_object($item) ? ($item->$key ?? null) : ($item[$key] ?? null);
            if (!in_array($val, $seen, true)) {
                $seen[] = $val;
                $result[] = $item;
            }
        }
        return $result;
    }

    /**
     * Paginate an array and return a slice with metadata.
     */
    public static function paginate(array $array, int $perPage = 15, int $page = 1): array
    {
        $total = count($array);
        $totalPages = (int) ceil($total / $perPage);
        $page = max(1, min($page, $totalPages));
        $offset = ($page - 1) * $perPage;
        return [
            'data'         => array_slice($array, $offset, $perPage),
            'total'        => $total,
            'per_page'     => $perPage,
            'current_page' => $page,
            'total_pages'  => $totalPages,
            'has_prev'     => $page > 1,
            'has_next'     => $page < $totalPages,
        ];
    }

    /**
     * Convert array to a key => value map (e.g. for <select> dropdowns).
     */
    public static function toMap(array $array, string $keyField, string $valueField): array
    {
        $map = [];
        foreach ($array as $item) {
            $k = is_object($item) ? $item->$keyField : $item[$keyField];
            $v = is_object($item) ? $item->$valueField : $item[$valueField];
            $map[$k] = $v;
        }
        return $map;
    }
}

// ─── Date Helpers ────────────────────────────────────────────────────────────

class Date
{
    /**
     * Get a human-readable "time ago" string.
     */
    public static function ago(string|\DateTimeInterface $date): string
    {
        $timestamp = $date instanceof \DateTimeInterface
            ? $date->getTimestamp()
            : strtotime($date);
        $diff = time() - $timestamp;

        if ($diff < 0) return 'just now';
        if ($diff < 60) return $diff . 's ago';
        if ($diff < 3600) return floor($diff / 60) . 'm ago';
        if ($diff < 86400) return floor($diff / 3600) . 'h ago';
        if ($diff < 604800) return floor($diff / 86400) . 'd ago';
        if ($diff < 2592000) return floor($diff / 604800) . 'w ago';
        if ($diff < 31536000) return floor($diff / 2592000) . 'mo ago';
        return floor($diff / 31536000) . 'y ago';
    }

    /**
     * Get a human-readable "time from now" string.
     */
    public static function fromNow(string|\DateTimeInterface $date): string
    {
        $timestamp = $date instanceof \DateTimeInterface
            ? $date->getTimestamp()
            : strtotime($date);
        $diff = $timestamp - time();

        if ($diff <= 0) return 'now';
        if ($diff < 60) return 'in ' . $diff . 's';
        if ($diff < 3600) return 'in ' . floor($diff / 60) . 'm';
        if ($diff < 86400) return 'in ' . floor($diff / 3600) . 'h';
        if ($diff < 604800) return 'in ' . floor($diff / 86400) . 'd';
        return 'in ' . floor($diff / 604800) . 'w';
    }

    /**
     * Format a date for display.
     */
    public static function format(string|\DateTimeInterface $date, string $format = 'M j, Y'): string
    {
        if ($date instanceof \DateTimeInterface) {
            return $date->format($format);
        }
        return date($format, strtotime($date));
    }

    /**
     * Check if a date string is valid.
     */
    public static function isValid(string $date, string $format = 'Y-m-d'): bool
    {
        $d = \DateTimeImmutable::createFromFormat($format, $date);
        return $d !== false && $d->format($format) === $date;
    }

    /**
     * Get the difference between two dates in a given unit.
     */
    public static function diff(string $from, string $to, string $unit = 'days'): int
    {
        $fromDate = new \DateTimeImmutable($from);
        $toDate = new \DateTimeImmutable($to);
        $interval = $fromDate->diff($toDate);

        return match ($unit) {
            'seconds' => abs($toDate->getTimestamp() - $fromDate->getTimestamp()),
            'minutes' => (int) floor(abs($toDate->getTimestamp() - $fromDate->getTimestamp()) / 60),
            'hours'   => (int) floor(abs($toDate->getTimestamp() - $fromDate->getTimestamp()) / 3600),
            'days'    => (int) $interval->days,
            'weeks'   => (int) floor($interval->days / 7),
            'months'  => $interval->y * 12 + $interval->m,
            'years'   => $interval->y,
            default   => (int) $interval->days,
        };
    }

    /**
     * Check if a date falls on a weekend.
     */
    public static function isWeekend(string|\DateTimeInterface $date): bool
    {
        $dayOfWeek = $date instanceof \DateTimeInterface
            ? (int) $date->format('N')
            : (int) date('N', strtotime($date));
        return $dayOfWeek >= 6;
    }

    /**
     * Get the start of the day (00:00:00).
     */
    public static function startOfDay(string $date = 'now'): string
    {
        return date('Y-m-d 00:00:00', strtotime($date));
    }

    /**
     * Get the end of the day (23:59:59).
     */
    public static function endOfDay(string $date = 'now'): string
    {
        return date('Y-m-d 23:59:59', strtotime($date));
    }

    /**
     * Generate an array of dates between two dates.
     */
    public static function range(string $from, string $to, string $step = '+1 day', string $format = 'Y-m-d'): array
    {
        $dates = [];
        $current = strtotime($from);
        $end = strtotime($to);
        while ($current <= $end) {
            $dates[] = date($format, $current);
            $current = strtotime($step, $current);
        }
        return $dates;
    }

    /**
     * Calculate age from a birth date.
     */
    public static function age(string $birthDate): int
    {
        return (new \DateTimeImmutable($birthDate))->diff(new \DateTimeImmutable())->y;
    }
}

// ─── Validation Helpers ──────────────────────────────────────────────────────

class Validate
{
    /**
     * Validate an email address.
     */
    public static function email(string $email): bool
    {
        return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
    }

    /**
     * Validate a URL.
     */
    public static function url(string $url): bool
    {
        return filter_var($url, FILTER_VALIDATE_URL) !== false;
    }

    /**
     * Validate an IP address (v4 or v6).
     */
    public static function ip(string $ip): bool
    {
        return filter_var($ip, FILTER_VALIDATE_IP) !== false;
    }

    /**
     * Validate an IPv4 address.
     */
    public static function ipv4(string $ip): bool
    {
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
    }

    /**
     * Validate an IPv6 address.
     */
    public static function ipv6(string $ip): bool
    {
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
    }

    /**
     * Validate a value is numeric (int or float string).
     */
    public static function numeric(mixed $value): bool
    {
        return is_numeric($value);
    }

    /**
     * Validate a value is within a numeric range.
     */
    public static function between(float|int $value, float|int $min, float|int $max): bool
    {
        return $value >= $min && $value <= $max;
    }

    /**
     * Validate string length is within bounds.
     */
    public static function length(string $value, int $min = 0, int $max = PHP_INT_MAX): bool
    {
        $len = mb_strlen($value);
        return $len >= $min && $len <= $max;
    }

    /**
     * Validate a string matches a regex pattern.
     */
    public static function regex(string $value, string $pattern): bool
    {
        return (bool) preg_match($pattern, $value);
    }

    /**
     * Validate a value is a valid JSON string.
     */
    public static function json(string $value): bool
    {
        json_decode($value);
        return json_last_error() === JSON_ERROR_NONE;
    }

    /**
     * Validate a credit card number using the Luhn algorithm.
     */
    public static function creditCard(string $number): bool
    {
        $number = preg_replace('/\D/', '', $number);
        if (strlen($number) < 13 || strlen($number) > 19) return false;

        $sum = 0;
        $alt = false;
        for ($i = strlen($number) - 1; $i >= 0; $i--) {
            $n = (int) $number[$i];
            if ($alt) {
                $n *= 2;
                if ($n > 9) $n -= 9;
            }
            $sum += $n;
            $alt = !$alt;
        }
        return $sum % 10 === 0;
    }

    /**
     * Validate a hex color code (#RGB or #RRGGBB).
     */
    public static function hexColor(string $color): bool
    {
        return (bool) preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color);
    }

    /**
     * Validate a date string against a format.
     */
    public static function date(string $date, string $format = 'Y-m-d'): bool
    {
        return Date::isValid($date, $format);
    }

    /**
     * Validate a password meets strength requirements.
     */
    public static function password(string $password, int $minLength = 8, bool $requireUpper = true, bool $requireLower = true, bool $requireDigit = true, bool $requireSpecial = false): array
    {
        $errors = [];
        if (mb_strlen($password) < $minLength) {
            $errors[] = "Must be at least {$minLength} characters";
        }
        if ($requireUpper && !preg_match('/[A-Z]/', $password)) {
            $errors[] = 'Must contain an uppercase letter';
        }
        if ($requireLower && !preg_match('/[a-z]/', $password)) {
            $errors[] = 'Must contain a lowercase letter';
        }
        if ($requireDigit && !preg_match('/\d/', $password)) {
            $errors[] = 'Must contain a digit';
        }
        if ($requireSpecial && !preg_match('/[^a-zA-Z0-9]/', $password)) {
            $errors[] = 'Must contain a special character';
        }
        return $errors;
    }

    /**
     * Validate a phone number (basic international format).
     */
    public static function phone(string $phone): bool
    {
        $digits = preg_replace('/\D/', '', $phone);
        return strlen($digits) >= 7 && strlen($digits) <= 15;
    }

    /**
     * Validate a slug (lowercase, alphanumeric, hyphens).
     */
    public static function slug(string $slug): bool
    {
        return (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug);
    }
}

// ─── Security Helpers ────────────────────────────────────────────────────────

class Secure
{
    /**
     * Hash a password using Argon2ID (falls back to bcrypt).
     */
    public static function hashPassword(string $password): string
    {
        if (defined('PASSWORD_ARGON2ID')) {
            return password_hash($password, PASSWORD_ARGON2ID);
        }
        return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
    }

    /**
     * Verify a password against a hash.
     */
    public static function verifyPassword(string $password, string $hash): bool
    {
        return password_verify($password, $hash);
    }

    /**
     * Check if a hash needs rehashing (algorithm/cost changed).
     */
    public static function needsRehash(string $hash): bool
    {
        $algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
        return password_needs_rehash($hash, $algo);
    }

    /**
     * Generate a cryptographically secure random token (hex).
     */
    public static function token(int $bytes = 32): string
    {
        return bin2hex(random_bytes($bytes));
    }

    /**
     * Generate a UUID v4.
     */
    public static function uuid(): string
    {
        $data = random_bytes(16);
        $data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
        $data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
    }

    /**
     * Escape output for safe HTML rendering.
     */
    public static function escape(string $value): string
    {
        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    }

    /**
     * Generate a CSRF token and store it in the session.
     */
    public static function csrfToken(): string
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start();
        }
        if (empty($_SESSION['_csrf_token'])) {
            $_SESSION['_csrf_token'] = self::token();
        }
        return $_SESSION['_csrf_token'];
    }

    /**
     * Verify a submitted CSRF token.
     */
    public static function verifyCsrf(string $token): bool
    {
        if (session_status() !== PHP_SESSION_ACTIVE) return false;
        return hash_equals($_SESSION['_csrf_token'] ?? '', $token);
    }

    /**
     * Generate an HMAC signature for data integrity.
     */
    public static function sign(string $data, string $key, string $algo = 'sha256'): string
    {
        return hash_hmac($algo, $data, $key);
    }

    /**
     * Verify an HMAC signature.
     */
    public static function verifySignature(string $data, string $signature, string $key, string $algo = 'sha256'): bool
    {
        return hash_equals(self::sign($data, $key, $algo), $signature);
    }

    /**
     * Encrypt data using AES-256-GCM.
     */
    public static function encrypt(string $data, string $key): string
    {
        $key = hash('sha256', $key, true);
        $iv = random_bytes(12);
        $tag = '';
        $ciphertext = openssl_encrypt($data, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
        return base64_encode($iv . $tag . $ciphertext);
    }

    /**
     * Decrypt data encrypted with encrypt().
     */
    public static function decrypt(string $payload, string $key): string|false
    {
        $key = hash('sha256', $key, true);
        $raw = base64_decode($payload);
        if ($raw === false || strlen($raw) < 28) return false;
        $iv = substr($raw, 0, 12);
        $tag = substr($raw, 12, 16);
        $ciphertext = substr($raw, 28);
        $result = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
        return $result !== false ? $result : false;
    }

    /**
     * Sanitize a filename to prevent directory traversal.
     */
    public static function sanitizeFilename(string $filename): string
    {
        $filename = basename($filename);
        $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
        return ltrim($filename, '.');
    }
}

// ─── HTTP Helpers ────────────────────────────────────────────────────────────

class Http
{
    /**
     * Perform a GET request using cURL.
     */
    public static function get(string $url, array $headers = [], int $timeout = 30): array
    {
        return self::request('GET', $url, null, $headers, $timeout);
    }

    /**
     * Perform a POST request with JSON body.
     */
    public static function post(string $url, array $data = [], array $headers = [], int $timeout = 30): array
    {
        $headers[] = 'Content-Type: application/json';
        return self::request('POST', $url, json_encode($data), $headers, $timeout);
    }

    /**
     * Perform a PUT request with JSON body.
     */
    public static function put(string $url, array $data = [], array $headers = [], int $timeout = 30): array
    {
        $headers[] = 'Content-Type: application/json';
        return self::request('PUT', $url, json_encode($data), $headers, $timeout);
    }

    /**
     * Perform a DELETE request.
     */
    public static function delete(string $url, array $headers = [], int $timeout = 30): array
    {
        return self::request('DELETE', $url, null, $headers, $timeout);
    }

    /**
     * Internal: execute a cURL request.
     */
    private static function request(string $method, string $url, ?string $body, array $headers, int $timeout): array
    {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => $timeout,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS      => 5,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_HEADER         => true,
        ]);

        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        }

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($response === false) {
            return ['status' => 0, 'headers' => [], 'body' => '', 'error' => $error];
        }

        $responseHeaders = substr($response, 0, $headerSize);
        $responseBody = substr($response, $headerSize);

        return [
            'status'  => $httpCode,
            'headers' => self::parseHeaders($responseHeaders),
            'body'    => $responseBody,
            'json'    => json_decode($responseBody, true),
            'error'   => null,
        ];
    }

    /**
     * Parse raw HTTP headers into an associative array.
     */
    private static function parseHeaders(string $raw): array
    {
        $headers = [];
        foreach (explode("\r\n", $raw) as $line) {
            if (str_contains($line, ':')) {
                [$key, $value] = explode(':', $line, 2);
                $headers[trim($key)] = trim($value);
            }
        }
        return $headers;
    }

    /**
     * Build a URL with query parameters.
     */
    public static function buildUrl(string $base, array $params = []): string
    {
        if (empty($params)) return $base;
        $separator = str_contains($base, '?') ? '&' : '?';
        return $base . $separator . http_build_query($params);
    }

    /**
     * Get the client's IP address (proxy-aware).
     */
    public static function clientIp(): string
    {
        $headers = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
        foreach ($headers as $header) {
            if (!empty($_SERVER[$header])) {
                $ip = explode(',', $_SERVER[$header])[0];
                $ip = trim($ip);
                if (filter_var($ip, FILTER_VALIDATE_IP)) {
                    return $ip;
                }
            }
        }
        return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    }

    /**
     * Check if the current request is AJAX.
     */
    public static function isAjax(): bool
    {
        return ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest';
    }

    /**
     * Send a JSON response and exit.
     */
    public static function json(mixed $data, int $status = 200): never
    {
        http_response_code($status);
        header('Content-Type: application/json; charset=UTF-8');
        echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    /**
     * Redirect to a URL and exit.
     */
    public static function redirect(string $url, int $status = 302): never
    {
        header('Location: ' . $url, true, $status);
        exit;
    }
}

// ─── File Helpers ────────────────────────────────────────────────────────────

class File
{
    /**
     * Read a file's contents, returning null on failure.
     */
    public static function read(string $path): ?string
    {
        if (!is_file($path) || !is_readable($path)) return null;
        $content = file_get_contents($path);
        return $content !== false ? $content : null;
    }

    /**
     * Write contents to a file (creates directories if needed).
     */
    public static function write(string $path, string $content): bool
    {
        $dir = dirname($path);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
        return file_put_contents($path, $content) !== false;
    }

    /**
     * Append content to a file.
     */
    public static function append(string $path, string $content): bool
    {
        $dir = dirname($path);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
        return file_put_contents($path, $content, FILE_APPEND) !== false;
    }

    /**
     * Delete a file.
     */
    public static function delete(string $path): bool
    {
        if (!is_file($path)) return false;
        return unlink($path);
    }

    /**
     * Check if a file exists and is readable.
     */
    public static function exists(string $path): bool
    {
        return is_file($path) && is_readable($path);
    }

    /**
     * Get the file size in a human-readable format.
     */
    public static function size(string $path): string
    {
        if (!is_file($path)) return '0 B';
        $bytes = filesize($path);
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $i = 0;
        while ($bytes >= 1024 && $i < count($units) - 1) {
            $bytes /= 1024;
            $i++;
        }
        return round($bytes, 2) . ' ' . $units[$i];
    }

    /**
     * Get the file extension (lowercase).
     */
    public static function extension(string $path): string
    {
        return strtolower(pathinfo($path, PATHINFO_EXTENSION));
    }

    /**
     * Get the MIME type of a file.
     */
    public static function mimeType(string $path): string
    {
        if (!is_file($path)) return 'application/octet-stream';
        return mime_content_type($path) ?: 'application/octet-stream';
    }

    /**
     * List files in a directory matching an optional glob pattern.
     */
    public static function list(string $directory, string $pattern = '*'): array
    {
        if (!is_dir($directory)) return [];
        $files = glob(rtrim($directory, '/') . '/' . $pattern);
        return $files !== false ? $files : [];
    }

    /**
     * Recursively list all files in a directory.
     */
    public static function listRecursive(string $directory, string $pattern = '*'): array
    {
        if (!is_dir($directory)) return [];
        $results = [];
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS)
        );
        foreach ($iterator as $file) {
            if ($file->isFile() && fnmatch($pattern, $file->getFilename())) {
                $results[] = $file->getPathname();
            }
        }
        return $results;
    }

    /**
     * Copy a file to a new location.
     */
    public static function copy(string $source, string $destination): bool
    {
        $dir = dirname($destination);
        if (!is_dir($dir)) mkdir($dir, 0755, true);
        return copy($source, $destination);
    }

    /**
     * Move / rename a file.
     */
    public static function move(string $source, string $destination): bool
    {
        $dir = dirname($destination);
        if (!is_dir($dir)) mkdir($dir, 0755, true);
        return rename($source, $destination);
    }

    /**
     * Read a JSON file and decode it.
     */
    public static function readJson(string $path): mixed
    {
        $content = self::read($path);
        if ($content === null) return null;
        return json_decode($content, true);
    }

    /**
     * Write data as a pretty-printed JSON file.
     */
    public static function writeJson(string $path, mixed $data): bool
    {
        $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        return self::write($path, $json . "\n");
    }

    /**
     * Read a CSV file into an array of associative arrays (using header row as keys).
     */
    public static function readCsv(string $path, string $delimiter = ','): array
    {
        if (!is_file($path)) return [];
        $handle = fopen($path, 'r');
        if (!$handle) return [];

        $headers = fgetcsv($handle, 0, $delimiter);
        if (!$headers) { fclose($handle); return []; }

        $rows = [];
        while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
            if (count($row) === count($headers)) {
                $rows[] = array_combine($headers, $row);
            }
        }
        fclose($handle);
        return $rows;
    }
}
