<?php
/**
 * BitFire PHP based Firewall.
 * Author: BitFire (BitSlip6 company)
 * Distributed under the AGPL license: https://www.gnu.org/licenses/agpl-3.0.en.html
 * Please report issues to: https://github.com/bitslip6/bitfire/issues
 * 
 * all functions are called via api_call() from bitfire.php and all authentication 
 * is done there before calling any of these methods.
 */

namespace ThreadFin;

use const BitFire\BITFIRE_VER;
use const BitFire\CONFIG_CACHE_TYPE;
use const BitFire\CONFIG_ENCRYPT_KEY;
use const BitFire\CONFIG_USER_TRACK_COOKIE;
use const BitFire\FILE_R;
use const BitFire\FILE_RW;
use const BitFire\FILE_W;
use const BitFire\MAX_FILE_READ;
use const BitFire\STATUS_OK;
use const BitFire\WAF_ROOT;
use const BitFire\WAF_SRC;

use ArrayAccess;
use \BitFire\Config as CFG;
use \BitFire\Block as Block;
use InvalidArgumentException;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionProperty;
use RuntimeException;
use stdClass;

use function BitFire\log_it;
use function BitFire\on_err;
use function BitFireChars\save_config2;
use function BitFirePlugin\file_type;
use function BitFireSvr\update_ini_value;
use function ThreadFin\HTTP\http;
use function \ThreadFin\partial as ƒixl;
use function \ThreadFin\partial_right as ƒixr;

//if (defined("ThreadFin\EN")) { return; }
//define("BitFire\_TF_UTIL", 1);


const DS = DIRECTORY_SEPARATOR;
const WEEK=86400*7;
const DAY=86400;
const HOUR=3600;
const MINUTE=60;

const ENCODE_RAW=1;
const ENCODE_SPECIAL=2;
const ENCODE_HTML=3;
const ENCODE_BASE64=4;

interface packable {
    public function pack() : string;
    public static function unpack(string $data);
}

/**
 * Complete filesystem abstraction
 * @package ThreadFin
 */
class FileData {
    /** @var string $filename - full path to file on disk */
    public string $filename;
    /** @var int $num_lines - number of lines of content */
    public int $num_lines = 0;
    /** @var array $lines - file content array of lines */
    public array $lines = [];
    public bool $debug = false;
    public int $size = 0;
    public string $content = "";
    /** @var bool $exists - true if file or mocked content exists */
    public bool $exists = false;
    /** @var bool $readable - true if file is readable */
    public bool $readable = false;
    /** @var bool $writeable - true if file is writeable */
    public bool $writeable = false;

    public bool $lock = false;
    protected $_fh;

    protected static $fs_data = [];
    protected array $errors = [];

    /**
     * mask file system with mocked $content at $filename
     * @param string $filename 
     * @param string $content 
     */
    public static function mask_file(string $filename, string $content) {
        FileData::$fs_data[$filename] = $content;
    }

    /**
     * @return array of any errors that may have occurred
     */
    public function get_errors() : array { return $this->errors; }

    /**
     * @param bool $enable enable or disable debug mode
     * @return FileData 
     */
    public function debug_enable(bool $enable) : FileData {
        $this->debug = $enable;
        return $this;
    }

    /**
     * preferred method of creating a FileData object
     */
    public static function create(string $filename, bool $lock = false) : FileData {
        return new FileData($filename, $lock);
    }

    /**
     * set the next read operation to locking
     * @return FileData 
     */
    public function lock() : FileData {
        $this->lock = true;
        return $this;
    }

    public function __construct(string $filename, bool $lock = false) {
        $this->filename = $filename;
        $this->lock = $lock;
        if (isset(FileData::$fs_data[$filename])) {
            $this->exists = $this->writeable = $this->readable = true;
            $this->size = strlen(FileData::$fs_data[$filename]);
        } else {
            $this->exists = file_exists($filename);
            $this->writeable = is_writable($filename);
            $this->readable = is_readable($filename);
            if ($this->exists) {
                $this->size = filesize($filename);
            }
        }
    }

    /**
     * This could be improved by marking content clean/dirty and joining only dirty content
     * @return string the raw file contents
     */
    public function raw() : string {
        if (empty($this->lines)) {
            if (isset(FileData::$fs_data[$this->filename])) {
                return FileData::$fs_data[$this->filename];
            } else {
                return file_exists($this->filename) ? file_get_contents($this->filename) : "";
            }
        }
        // return the rejoined lines
        if (is_array($this->lines)) {
            return join("", $this->lines);
        }
        if (is_string($this->lines)) {
            return $this->lines;
        }
        return "";
    }


    /**
     * read the data from disk and store in lines
     * @return FileData 
     */
    public function read($with_newline = true) : FileData {
        // mock data, and raw reads
        if (isset(FileData::$fs_data[$this->filename])) {
            $this->lines = explode("\n", FileData::$fs_data[$this->filename]);
            $this->num_lines = count($this->lines);
            return $this;
        }

        // missing file!
        if (! $this->exists) {
            debug("file does not exist: %s", $this->filename);
            $this->errors[] = "File does not exist: {$this->filename}";
            return $this;
        }

        $size = filesize($this->filename);
        if ($size > MAX_FILE_READ) {
            $this->errors[] = "File too large to read: {$this->filename}";
            return $this;
        }

        // read the file and lock it
        if ($this->lock) {
            $mode = ($this->writeable) ? "r+" : "r";
            $this->_fh = fopen($this->filename, $mode);
            if (!empty($this->_fh)) {
                flock($this->_fh, LOCK_EX);
                $buffer = "";
                $max_reads = (MAX_FILE_READ / 8192);
                while(feof($this->_fh) === false && $max_reads-- > 0) {
                    $buffer .= fread($this->_fh, 8192);
                }
                $this->lines = explode("\n", $buffer);
                if ($with_newline) {
                    $this->lines = array_map(ƒixr('\ThreadFin\append_str', "\n"), $this->lines);
                }
            } else {
                $this->lines = [];
            }
        } else {
            // just read the file (no locking)
            $new_line_flag = ($with_newline) ? 0 : FILE_IGNORE_NEW_LINES;
            $tmp = file($this->filename, $new_line_flag);
            $this->lines = ($tmp === false) ? [] : $tmp;
        }

        // count lines and handle any error cases...
        if (empty($this->lines)) {
            debug("unable to read %s", $this->filename);
            $this->lines = [];
            $this->num_lines = 0;
        } else {
            $this->num_lines = count($this->lines);
        }

        if ($this->debug) {
            debug("FS(r) [%s] (%d)lines", $this->filename, $this->num_lines);
        }

        // make sure lines is a valid value
        if ($size > 0 && $this->num_lines < 1) {
            debug("empty file %s", $this->filename);
            $this->lines = [];
        }

        return $this;
    }

    /**
     * MUTATE $lines
     * @param callable $fn - apply function to the array of lines
     * @return FileData 
     */
    public function apply_fn(callable $fn) : FileData {
        if ($this->num_lines > 0) {
            $this->lines = $fn($this->lines);
            $this->num_lines = count($this->lines);
        } else {
            $this->errors[] = "unable to apply fn[".func_name($fn)."] has no lines";
        }
        return $this;
    }

    /**
     * MUTATE $lines
     * @return FileData with lines joined and json decoded
     */
    public function un_json() : FileData {
        // UGLY, refactor this
        if (count($this->lines) > 0) {
            $data = join("\n", $this->lines);
            $result = false;
            if (!empty($data) && is_string($data)) {
                $result = un_json($data);
            }
            if (is_array($result)) {
                $this->lines = $result;
                $this->num_lines = count($this->lines);
            }
            else {
                $this->lines = array();
                $this->errors[] = "json decode failed";
            }
        }
        return $this;
    }

    /**
     * @param callable $fn array_filter on $this->lines with $fn
     * @return FileData 
     */
    public function filter(callable $fn) : FileData {
        $this->lines = array_values(array_filter($this->lines, $fn));
        $this->num_lines = count($this->lines);
        //if (!empty($this->content)) { $this->content = join("\n", $this->lines); }
        return $this;
    }

    /**
     * @param string $text test to append to FileData
     * @return FileData 
     */
    public function append(string $text) : FileData {
        $lines = explode("\n", $text);
        $check_line = $lines[0]??"";
        foreach ($lines as $line) {
            if (!empty($line) && strlen($line) > 5) {
                $check_line = $line;
                break;
            }
        }
        if (!empty($check_line)) {
            $normalized = array_map(fn($s) => rtrim($s, "\r\n"), $this->lines);
            if (!in_array(rtrim($check_line, "\r\n"), $normalized, true)) {
                foreach ($lines as $line) {
                    $this->lines[] = "$line\n";
                }
            }
        }
        return $this;
    }

    /**
     * MUTATES $lines
     * @param callable $fn array_map on $this->lines with $fn
     * @return FileData 
     */
    public function map(callable $fn) : FileData {
        $this->num_lines = count($this->lines);
        if ($this->num_lines > 0) {
            $this->lines = array_map($fn, $this->lines);
        } else {
            trace("E_MAP");
        }
        return $this;
    }

    /**
     * reduces all $lines to a single value
     * @param callable $fn ($carry, $item)
     * @return - result of $fn() - (should be a new array of lines)
     */
    public function reduce(callable $fn, $initial = NULL) {
        return array_reduce($this->lines, $fn, $initial);
    }

    public function __invoke() : array {
        return $this->lines;
    }

    // return a file modification effect for current FileData
    public function file_mod($mode = 0, $mtime = 0) : FileMod {
        return new FileMod($this->filename, $this->raw(), $mode, $mtime);
    }

    /**
     * @return int the file modification time, or 0 if the file does not exist
     */
    public function mtime() : int {
        return is_file($this->filename) ? (int)@filemtime($this->filename) : 0;
    }

    public function unlock() {
        if (!empty($this->_fh)) {
            @flock($this->_fh, LOCK_UN);
        }
    }

    public function close() : void {
        if (!empty($this->_fh)) {
            $this->unlock();
            fclose($this->_fh);
        }
    }

    public function __destruct() { $this->close(); }
}

    
    

// developer debug functions
function mark(?string $msg = null) {
    static $last = 0; if (is_null($msg)) { return $last; }
    $last = microtime(true); trace($msg);
}
/**
 * helper emergency logger with no depenedencies, 
 * @deprecated only used for development debugging
 */
function emerg(string $msg) :void {
    file_put_contents("/tmp/bitfire_emerg.log", date('Y-m-d H:i:s') . " " . $msg . "\n", FILE_APPEND);
}
/**
 * helper debug function to print a variable and exit
 * @param mixed $x 
 * @param string $msg 
 * @return never 
 */
function dbg($x, $msg="") {$m=htmlspecialchars($msg); $z=(php_sapi_name() == "cli") ? print_r($x, true) : htmlspecialchars(print_r($x, true)); echo "<pre>\n[$m]\n($z)\n" . join("\n", debug(null)) . "\n" . debug(trace(null));
    $now = microtime(true); $last = mark(null); $ms = "-";
    if ($last > 0) {
        $time = $now - $last;
        $ms = sprintf("%0.3f", $time * 1000);
    }
    debug_print_backtrace();
    die("\nFIN [$ms]");
}
function do_for_each(array $data, callable $fn) { $r = array(); foreach ($data as $elm) { $r[] = $fn($elm); } return $r; }
function do_for_all_key(array $data, callable $fn) { foreach ($data as $key => $item) { $fn($key); } }
function do_for_all_key_value(array $data, callable $fn) { foreach ($data as $key => $item) { $fn($key, $item); } }
function find_regex_reduced($value) : callable { return function($initial, $argument) use ($value) { return (preg_match("/$argument/", $value) <= 0 ? $initial : $value); }; }
function starts_with(string $haystack, string $needle) { return (substr($haystack, 0, strlen($needle)) === $needle); } 
function ends_with(string $haystack, string $needle) { return strrpos($haystack, $needle) === \strlen($haystack) - \strlen($needle); } 
function ends_with_any(string $haystack, array $needles) {
    $hay_len = \strlen($haystack);
    foreach ($needles as $needle) {
        if (strrpos($haystack, $needle) === $hay_len - \strlen($needle)) {
            return true;
        }
    }
    return false;
}
function random_str(int $len) : string { return substr(strtr(base64_encode(random_bytes($len)), '+/=', '___'), 0, $len); }
function un_json(?string $data="") : ?array {
    if (empty($data)) { return []; }
    $d = trim($data, "\n\r,");
    $j = json_decode($d, true, 32); $r = [];
    if (is_array($j)) { $r = $j; }
    else { 
        $max_len = min(24, strlen($d)); 
        debug("ERROR un_json [%s ... %s]", substr($d, 0, $max_len), substr($d, -$max_len));
        return null;
    }
    return $r; }
function en_json($data, $pretty = false) : string { 
    $mode = $pretty ? JSON_PRETTY_PRINT : 0;
    $j = json_encode($data, $mode);
    if ($j == false && function_exists('BitFire\on_err')) {
        $enum = json_last_error();
        $msg = json_last_error_msg();
        // XXX replace with caller file:line
        on_err($enum, $msg, __FILE__, __LINE__);
    }
    return ($j == false) ? "" : $j;
}
function contains(?string $haystack, $needle) : bool { if (empty($haystack) || empty($needle)) { return false; } if(is_array($needle)) { foreach ($needle as $n) { if (!empty($n) && strpos($haystack, $n) !== false) { return true; } } return false; } else { return strpos($haystack, $needle) !== false; } }
function icontains(?string $haystack, $needle) : bool { if(is_array($needle)) { foreach ($needle as $n) { if (!empty($n) && stripos($haystack, $n) !== false) { return true; } } return false; } else { return stripos($haystack, $needle) !== false; } }
// return the $index element of $input split by $separator or '' on any failure
function at(?string $input, string $separator, int $index, string $default='') : string {
    if (empty($input)) { return ''; }
    $parts = explode($separator, $input);
    return (isset($parts[$index])) ? $parts[$index] : $default;
}

/** return (bool)!$input */
function find_fn(string $fn) : callable { if (function_exists("BitFirePlugin\\$fn")) { return "BitFirePlugin\\$fn"; } if (function_exists("BitFire\\$fn")) { return "BitFire\\$fn"; } error("no plugin function: %s", $fn); return "BitFire\\id"; }
function find_const_arr(string $const, array $default=[]) : array { 
    if (defined("BitFirePlugin\\$const")) { return constant("BitFirePlugin\\$const"); }
    if (defined("BitFire\\$const")) { return constant("BitFire\\$const"); }
    return $default;
}

// set key on object|array data to value if key is currently not set

function set_if_empty($data, $key, $value) { if (is_object($data) && (!isset($data->$key) || empty($data->$key))) { $data->$key = $value; } if (is_array($data) && (!isset($data[$key]) || empty($data[$key]))) { $data[$key] = $value; } return $data; }

// find an element that matches !empty($fn(x)) or NULL
function find(array $list, callable $fn) { foreach ($list as $item) { $x = $fn($item); if (!empty($x)) { return $x; }} return NULL; }
function ƒ_id($data = '') : callable { return function () use ($data) { return $data; }; }
function ƒ_inc($amount = 1) : callable { return function ($input = 0) use ($amount) { trace("INC:$input:$amount"); return intval($input) + $amount; }; }
function ƒ_match($match = '') : callable { return function ($input) use ($match) { return $match == $input; }; }
function fn_carry($fn) { return function($carry, $item) use ($fn) {
    $carry[] = $fn($item);
    return $carry;
    };
}
function not(bool $x) : bool { return !$x; }
function not_fn(callable $x) : callable { return function($x) { return !$x; }; }


function xxh3(string $data) : int {
    try {
        $bin = hash('xxh3', $data, true);
        $u32 = unpack('Vv', substr($bin, 0, 4));
        return (int)$u32['v'];
    } catch (\Throwable $e) {
        return 0;
    }
}

function array_add_value(array $keys, callable $fn) : array { $result = array(); foreach($keys as $x) {$result[$x] = $fn($x); } return $result;}
function append_str(string $input, string $to_append) : string { return $input . $to_append; }

// return the substring after the first occurrence of $needle in $haystack or $default if not found
function str_after_first(string $haystack, string $needle, string $default = ''): string {
    if ($needle === '') return $default;                // avoid infinite "match"
    $pos = strpos($haystack, $needle);                  // case-sensitive; use stripos() for i-case
    return ($pos === false) ? $default : substr($haystack, $pos + strlen($needle));
}

function len(string $input) : int { return function_exists('\mb_strlen') ? mb_strlen($input) : poly_strlen($input); }

function compact_array(?array $in) : array { $result = []; foreach ($in as $x) { $result[] = $x; } return $result; }


// polyfill for mb_strlen
function poly_strlen($s) {
    for ($i=0; $i<192; $i++) {
        if (!isset($s[$i])) { break; }
    }
    return $i;
}

/**
 * Normalize a path (trim, remove trailing slash except root, collapse //).
 */
function path_normal(?string $p): string {
    if ($p === null) return '';
    $p = trim($p);
    // unify separators
    $p = preg_replace('#/{2,}#', '/', $p);
    // remove trailing slash except root
    if ($p !== '/' && ends_with($p, '/')) {
        $p = rtrim($p, '/');
    }
    // make sure we have a full path
    if (!$p[0] === '/') {
        $p = '/' . $p;
    }   
    return $p;
}

/**
 * True if $child is the same as or inside $parent (string-safe, not realpath).
 */
function path_within(string $child, string $parent): bool {
    $child  = path_normal($child)  ?? '';
    $parent = path_normal($parent) ?? '';
    if ($parent === '' || $child === '') return false;
    if ($parent === '/') return true;
    $parentSlash = $parent . '/';
    return ($child === $parent) || starts_with($child . '/', $parentSlash);
}

// map keys from data into object public properties
function map_to_object(array $data, object $object): object
{
    /** @var array<string, array<string, array{cast:int, nullable:bool}>> */
    static $cache = [];

    $class = get_class($object);

    if (!array_key_exists($class, $cache)) {
        $rc   = new ReflectionClass($class);
        $map  = [];

        foreach ($rc->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) {
            if ($prop->isStatic()) {
                continue;
            }
            // PHP 8.1+: readonly; guard with method_exists for older PHP
            if (method_exists($prop, 'isReadOnly') && $prop->isReadOnly()) {
                continue;
            }

            $rt = $prop->getType();
            $nullable = $rt ? $rt->allowsNull() : true; // untyped props allow null
            $castCode = 0; // 0 = no cast; 1=int; 2=float; 3=string; 4=bool; 5=array

            if ($rt instanceof ReflectionNamedType) {
                $t = $rt->getName();
                // Normalize scalars to small switch-friendly ints
                // (keep this tiny for branch predictor / cache friendliness)
                if ($t === 'int' || $t === 'integer') {
                    $castCode = 1;
                } elseif ($t === 'float' || $t === 'double') {
                    $castCode = 2;
                } elseif ($t === 'string') {
                    $castCode = 3;
                } elseif ($t === 'bool' || $t === 'boolean') {
                    $castCode = 4;
                } elseif ($t === 'array') {
                    $castCode = 5;
                }
                // else: object/class/closure/resource/mixed… → no cast (0)
            } else {
                // Union / intersection types: let PHP enforce at assignment.
                // (Casting unions correctly is slow; skip for performance.)
            }

            $map[$prop->getName()] = ['cast' => $castCode, 'nullable' => $nullable];
        }

        $cache[$class] = $map; // cache even if empty array
    }

    $types = $cache[$class];
    if ($types === []) {
        // No writable public props: nothing to map
        return $object;
    }

    foreach ($data as $key => $value) {
        // Only touch known public instance props (no dynamic properties).
        if (!isset($types[$key])) {
            continue;
        }

        $meta = $types[$key];

        if ($value === null) {
            if ($meta['nullable']) {
                $object->$key = null;
            }
            // If not nullable, skip; PHP would throw on null assignment.
            continue;
        }

        // Cheap scalar casts only; unions/classes left to PHP’s type system.
        switch ($meta['cast']) {
            case 1: $value = (int)$value;    break;
            case 2: $value = (float)$value;  break;
            case 3: $value = (string)$value; break;
            case 4: $value = (bool)$value;   break; // fastest; "false"->true by PHP rules
            case 5: $value = (array)$value;  break;
            // default 0: no cast
        }

        $object->$key = $value; // Let PHP enforce non-scalar & union types
    }

    return $object;
}



/**
 * return sub directories for a single directory. non-recursive. non-pure
 * @param string $dirname to search
 * @return array 
 */
function get_sub_dirs(string $dirname) : array {
    $dirs = array();
    if (!file_exists($dirname)) { debug("unable to find sub-dirs [%s]", $dirname); return $dirs; }

    if ($dh = \opendir($dirname)) {
        while(($file = \readdir($dh)) !== false) {
            $path = $dirname . '/' . $file;
            if (!$file || $file === '.' || $file === '..') {
                continue;
            }
            if (is_dir($path) && $dirname !== $path && !is_link($path)) {
                $dirs[] = $path;
			}
        }
        \closedir($dh);
    }

    return $dirs;
}


/**
 * recursively perform a function over directory traversal.
 */
function file_recurse(string $dirname, callable $fn, ?string $regex_filter = NULL, array $result = array(), $max_results = 20000) : array {
    $max_files = 20000;
    $result_count = count($result);
    if (!file_exists($dirname)) { 
        debug("[%s] not exist", $dirname);
        return $result;
    }

    if ($dh = \opendir($dirname)) {
        while(($file = \readdir($dh)) !== false && $max_files-- > 0 && $result_count < $max_results) {
            $path = $dirname . '/' . $file;
            if (!$file || $file === '.' || $file === '..') {
                continue;
            }
            if (($regex_filter != NULL && preg_match($regex_filter, $path)) || $regex_filter == NULL) {
                $x = $fn($path);
                if (!empty($x)) { $result[] = $x; $result_count++; }
            }
            if (is_dir($path) && !is_link($path)) {
                if (!preg_match("#\/uploads\/?$#", $path)) {
                    $result = file_recurse($path, $fn, $regex_filter, $result, $max_results);
                    $result_count = count($result);
                }
			}
        }
        \closedir($dh);
    }

    return $result;
}




/**
 * yield all matching files in a directory recursively
 */
function index_yield(string $index_file, int $max_lines = 200) : ?\Generator {
    if (!file_exists($index_file)) { return NULL; } 
    static $counter = 0;
    static $yielded = 0;

    $fh = fopen($index_file, "r+");
    $size = filesize($index_file);
    if ($size <= 2) { return NULL; }

    $block_size = 384;
    $truncate_size = 0;
    $read_size = 0;
    $last_read = $size;
    $next_ptr = max(0, $size - $block_size);
    //$next_ptr = 0;
    $line = "";

    $full_read = 0;
    while($yielded < $max_lines && $next_ptr >= 0 && $last_read > 0 && ++$counter < $max_lines*2) {
        if (fseek($fh, $next_ptr, SEEK_SET) < 0) { 
            continue;
        }

        if ($next_ptr == 0) { 
            $block_size = 1024;
        }
        $tmp = fread($fh, $block_size);
        $idx2 = strrpos(rtrim($tmp), "\n");
        if (!$idx2) {
            continue;
        }
        $line = substr($tmp, $idx2);
        $read_size = strlen($line);
        $full_read += $read_size;
        //$full_size = strlen($tmp);
        $next_ptr = max(0, $next_ptr - $read_size);

        $line = trim($line);
        if (substr($line, 0, 1) !== DS) { 
            $line = DS . $line;
        }

        if (!file_exists($line)) {
            continue;
        }
        $yielded++;
        yield $line;
    }

    if ($last_read >= $size) {
        $parts = explode(".php", $tmp);
        foreach ($parts as $part) {
            yield trim($part . ".php");
        }
    }

    fseek($fh, 0, SEEK_SET);
    rewind($fh);
    fflush($fh);

    // only truncate to last newline!!
    $r = ftruncate($fh, max(0, $size - $full_read + 1));
    if (!$r) {
        error("unable to truncate index file");
    }
    //$r_txt = ($r) ? "TRUE" : "FALSE";
    rewind($fh);
    fclose($fh);
    if ($size - $truncate_size < 256) {
        return NULL;
    }
}


/**
 * yield all matching files in a directory recursively
 * only used in api.php
 */
function file_index(string $dirname, ?string $include_regex_filter = NULL, ?callable $write_fn = NULL, ?bool $root = true) {
    if (!is_dir($dirname)) { return; }
    static $examined = [];
    // reset the counter for root calls
    if ($root) { $examined = []; }

    $backup_regex = "/".str_replace(DS, "\\".DS, $dirname)."\\".DS."wp-content\\".DS."updraft\\".DS."/";
    if ($dh = \opendir($dirname)) {
        while(($file = \readdir($dh)) !== false) {
            // skip "dot" files
            if (!$file || $file === '.' || $file === '..') {
                continue;
            }
            $path = $dirname . DS . $file;


            // recurse if it is a directory
            if (is_dir($path)) {
                // disallow directory recursion loops
                if (isset($examined[$path])) { continue; }
                // mark this path as examined
                $examined[$path] = 1;

                file_index($path, $include_regex_filter, $write_fn, false);
            } else {

                // check if the path matches the regex filter, or has no filter
                if (($include_regex_filter != NULL && preg_match($include_regex_filter, $path)) || $include_regex_filter == NULL) {
                    //debug("yield ($yielded) [$counter] < $skip_files ($max_files)");
                    if (preg_match($backup_regex, $path)) { continue; }
                    $write_fn("$path\n");
                }
            }
        }
        \closedir($dh);
    }
}



/**
 * returns a function that will cache the call to $fn with $key for $ttl
 * NOTE: $fn must return an array or a string (see: load_or_cache)
 */
function memoize(callable $fn, string $key, int $ttl) : callable {
    return function(...$args) use ($fn, $key, $ttl) {
        if (CFG::str(CONFIG_CACHE_TYPE) !== 'nop') {
            return CacheStorage::get_instance()->load_or_cache($key, $ttl, ƒixl($fn, ...$args));
        }
        else {
            debug("unable to memoize [%s]", func_name($fn));
            return $fn(...$args);
        }
    };
}

/**
 * functional helper for partial application
 * lock in left parameter(s)
 * $log_it = partial("log_to", "/tmp/log.txt"); // function log_to($file, $content)
 * assert_eq($log_it('the log line'), 12, "partial app log to /tmp/log.txt failed");
 */
function partial(callable $fn, ...$args) : callable {
    return function(...$x) use ($fn, $args) { return $fn(...array_merge($args, $x)); };
}

/**
 * same as partial, but reverse argument order
 * lock in right parameter(s)
 * $minus3 = partial_right("minus", 3);  //function minus ($subtrahend, $minuend)
 * assert_eq($minus3(9), 3, "partial app of -3 failed");
 */
function partial_right(callable $fn, ...$args) : callable {
    return function(...$x) use ($fn, $args) { return $fn(...array_merge($x, $args)); };
}


/**
 * Effect runner helper
 */
function header_send(string $key, ?string $value) : void {
    // wp-cli guard
    if (!function_exists('headers_sent')) { return; }
    if (headers_sent($file, $line)) {
        if (php_sapi_name() == 'cli') { return; }
        $msg = sprintf("headers already sent in %s:%d, unable to send: [%s:%s]", $file, $line, $key, $value);
        user_error($msg, E_USER_NOTICE);
    } else {
        $normal_key = strtolower($key);
        if ($value === null) {
            header_remove($normal_key);
        } else {
            header("$normal_key: $value");
        }
    }
}


class FileMod {
    public string $filename;
    public string $content;
    public int $write_mode;
    public int $mod_time;
    public bool $append;
    public bool $atomic;
    /**
     * 
     * @param string $filename 
     * @param string $content 
     * @param int $write_mode - if != 0, passed to chmod()
     * @param int $mod_time  - if != 0, passed to touch()
     * @param bool $append  - true to append to the file
     * @param bool $atomic - true 
     * @return void 
     */
    public function __construct(string $filename, string $content, int $write_mode = 0, int $mod_time = 0, bool $append = false, bool $atomic = false) {
        $this->filename = $filename;
        $this->content = $content;
        $this->write_mode = $write_mode;
        $this->mod_time = $mod_time;
        $this->append = $append;
        $this->atomic = $atomic;
    }
}


/**
 * abstract away effects
 */
class Effect {
    private $out = '';
    private $cookie = '';
    private $response = 0;
    private $hide_output = false;
    private $status = STATUS_OK;
    private $exit = false;
    private $headers = array();
    private $cache = array();
    public $file_outs = array();
    private $api = array();
    private $unlinks = array();
    private $errors = array();

    public static function create() : Effect { assert(func_num_args() == 0, "incorrect call of Effect::create()"); return new Effect(); }
    public static $NULL;

    // response content effect
    public function out(string $line, int $encoding = ENCODE_RAW, bool $replace = false) : Effect { 
        switch ($encoding) {
            case ENCODE_SPECIAL:
                $tmp = htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 
                break;
            case ENCODE_HTML:
                $tmp = htmlentities($line, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 
                break;
            case ENCODE_BASE64:
                $tmp = base64_encode($line); 
                break;
            default:
                $tmp = $line; 
                break;
        }
        if ($replace) { $this->out = $tmp; }
        else { $this->out .= $tmp; }
        return $this;
    }
    // response header effect
    public function header(string $name, ?string $value) : Effect { $this->headers[$name] = $value; return $this; }
    // remove any response headers
    public function clear_headers() : Effect { $this->headers = array(); return $this; }
    // response cookie effect
    public function cookie(string $value, string $id = "") : Effect { $this->cookie = $value; trace("COOK:$id"); return $this; }
    // response code effect
    public function response_code(int $code) : Effect { $this->response = $code; return $this; }
    // update cache entry effect
    public function update(CacheItem $item) : Effect { $this->cache[$item->key] = $item; return $this; }
    // exit the script effect (when run is called), 2 helpers for setting error conditions, newline added to $out
    public function exit(bool $should_exit = true, ?int $status = null, ?string $out = null) : Effect { 
        $this->exit = $should_exit; 
        if ($status != null) {
            assert(is_numeric($status), "exit status must be numeric [$status]");
            $this->status = $status;
        }
        if ($out != null) { $this->out .= "\n$out"; }
        return $this;
    }
    // an effect status code that can be read later
    public function status(int $status) : Effect { $this->status = $status; return $this; }
    // an effect to write a file to the filesystem.  if a previous entry for the same file exists, it is overwritten
    public function file(FileMod $mod) : Effect {
        assert(!empty($mod->filename), "file problem %s"); 

        // someone is doing something they shouldn't..
        if (strpos($mod->filename, '..') !== false || strpos($mod->filename, '..\\') !== false) {
            user_error("file name contains illegal '..' sequence: %s", $mod->filename);
            $this->errors[] = "file name contains illegal '..' sequence: {$mod->filename}";
            return $this;
        }

        // if not appending, we want to overwrite any current content for same file
        if (! $mod->append) {
            $outs = array_filter($this->file_outs, function($x) use ($mod) { return $x->filename != $mod->filename; });
            $outs[] = $mod;
            $this->file_outs = $outs;
        }
        // appending, so just add to the list of edits
        else {
            $this->file_outs[] = $mod;
        }
        return $this;
    }
    // one liner for api output
    public function api(bool $success, string $note, array $data=[]) : Effect { $this->api['success'] = $success; $this->api['note'] = $note; $this->api['data'] = $data; return $this; }
    // add a file to the list of files to remove
    public function unlink(string $filename) : Effect { $this->unlinks[] = $filename; return $this; }
    // don't display any output
    public function hide_output(bool $hide = true) : Effect { $this->hide_output = $hide; return $this; }

    public function chain(Effect $effect) : Effect { 
        $this->out .= $effect->read_out();
        $this->cookie .= $effect->read_cookie();
        $this->response = $this->set_if_default('response', $effect->read_code(), 0);
        $this->status = $this->set_if_default('status', $effect->read_status(), STATUS_OK);
        $this->exit = $this->set_if_default('exit', $effect->read_exit(), false);
        $this->set_if_default('headers', $effect->read_headers(), [], true);
        $this->set_if_default('cache', $effect->read_cache(), []);
        $this->set_if_default('file_outs', $effect->read_files(), []);
        $this->set_if_default('api', $effect->read_api(), [], true);
        $this->set_if_default('unlinks', $effect->read_unlinks(), []);
        return $this;
    }

    // helper function for effect chaining
    protected function set_if_default($pname, $value, $default) {
        if (is_array($this->$pname) && !empty($value)) {
            if (is_array($value)) {
                $this->$pname = array_merge($this->$pname, $value);
            } else {
                $this->$pname[] = $value;
            }
        }
        else if (!empty($value) && $this->$pname === $default) { return $value; }
        return $this->$pname;
    }

    // return true if the effect will exit 
    public function read_exit() : bool { return $this->exit; }
    // return the effect content
    public function read_out(bool $clear = false) : string { $t = $this->out; if ($clear) { $this->out = ""; } return $t; }
    // return the effect headers
    public function read_headers() : array { return $this->headers; }
    // return the effect cookie (only 1 cookie supported)
    public function read_cookie() : string { return $this->cookie; }
    // return the effect cache update
    public function read_cache() : array { return $this->cache; }
    // return the effect response code
    public function read_code() : int { return $this->response; }
    // return the effect function status code
    public function read_status() : ?int { return $this->status; }
    // return the effect filesystem changes
    /** @return array<FileMod>  */
    public function read_files() : array { return $this->file_outs; }
    // return the API result output
    public function read_api() : array { return $this->api; }
    // return the list of files to unlink
    public function read_unlinks() : array { return $this->unlinks; }
    // return  the list of errors after a run, should be empty
    public function read_errors() : array { return $this->errors; }

    // TODO: monitor runner for failures and log/report them
    public function run() : Effect {
        
        // first delete any files, that way we can remove and re-create in the same effect..
        do_for_each($this->unlinks, '\ThreadFin\recursive_delete');

        // update cache entries
        if (!empty($this->cache)) {
            $items = array_values($this->cache);
            $cache = CacheStorage::get_instance();
            array_walk($items, function (CacheItem $item) use ($cache) {
                $cache->update_data($item->key, $item->fn, $item->init, $item->ttl, $item->flags);
            });
        }

        // send the headers before the content...
        if (php_sapi_name() != 'cli') {
            // send custom headers
            if (count($this->headers) > 0) {
                do_for_all_key_value($this->headers, '\ThreadFin\header_send');
            }
        }
 

        // write all effect files
        foreach ($this->file_outs as $file) {

            assert(!empty($file->filename), "can't write to null file: " . en_json($file));

            $len = strlen($file->content);
            $mods = ($file->append ? FILE_APPEND : 0) | LOCK_EX;

            debug("FS(w) [%s] (%d)bytes\n%s\n", $file->filename, $len, $file->content);

            // ensure directory exists
            $dir = dirname($file->filename);
            if (!is_dir($dir)) {
                if (!mkdir($dir, 0755, true)) {
                    $this->errors[] = "unable to mkdir -r [$dir]";
                    continue;
                }
            }

            // ensure write-ability of existing file
            if (file_exists($file->filename) && !is_writable($file->filename)) {
                if (!chmod($file->filename, FILE_RW)) {
                    $this->errors[] = "unable to make {$file->filename} writeable";
                    continue;
                }
            }

            $ok = false;
            $is_php = (ends_with($file->filename, '.php'));
            if (!empty($file->atomic)) {
                // atomic path, build full target content if append is requested
                $data = $file->content;
                if (!empty($file->append) && file_exists($file->filename) && is_readable) {
                    $existing = @file_get_contents($file->filename);
                    if ($existing === false) {
                        $existing = '';
                    }
                    $data = $existing . $file->content;
                }

                $data_len = strlen($data);
                $tmp_file = $file->filename . '.' . bin2hex(random_bytes(6));

                // write new content to temp file
                $written = @file_put_contents($tmp_file, $data, LOCK_EX);
                if ($written !== $data_len) {
                    $e = error_get_last();
                    debug("file mod write error [%s] (%d/%d bytes)", basename($file->filename), (int)$written, $data_len);
                    $this->errors[] = "failed to write temp file for {$file->filename} {$data_len} bytes. " . en_json($e);
                    @unlink($tmp_file);
                } else {
                    // atomically replace target
                    if (!@rename($tmp_file, $file->filename)) {
                        $this->errors[] = "failed to rename temp file {$tmp_file} to {$file->filename}, (compile failed?)";
                        @unlink($tmp_file);
                    } else {
                        $ok = true;
                    }
                }
            } else {
                // non-atomic path, write directly with optional append
                $written = @file_put_contents($file->filename, $file->content, $mods);
                if ($written !== $len) {
                    $e = error_get_last();
                    debug("file mod write error [%s] (%d/%d bytes)", basename($file->filename), (int)$written, $len);
                    $this->errors[] = "failed to write file: {$file->filename} {$written}/{$len} bytes. " . en_json($e);
                } else {
                    $ok = true;
                }
            }

            if ($ok) {
                // post write attributes
                if (!empty($file->mod_time) && $file->mod_time > 0) {
                    if (!@touch($file->filename, $file->mod_time)) {
                        $this->errors[] = "unable to set {$file->filename} mod_time to: " . $file->mod_time;
                    }
                }
                if (!empty($file->write_mode) && $file->write_mode > 0) {
                    if (!@chmod($file->filename, $file->write_mode)) {
                        $this->errors[] = "unable to chmod {$file->filename} perm: " . $file->write_mode;
                    }
                }
            }

            // refresh opcache for the final path if present
            /*
            if ($is_php && function_exists('token_get_all')) {
                try {
                    $tokens = token_get_all(file_get_contents($file->filename)); // will throw on parse error
                    if (empty($tokens)) {
                        $this->errors[] = "PHP file compiles empty file ({$file->filename})";
                        @unlink($file->filename);
                    }
                } catch (\Exception $e) {
                    $this->errors[] = "PHP file won't compile ({$file->filename})";
                    @unlink($file->filename);
                }
            }
            */

            // clear the stat cache
            clearstatcache(true, $file->filename);
        }
        clearstatcache(false);

        // output api and error data if we are not set to hide it
        if (!$this->hide_output) {
            // API output, force JSON
            if (!empty($this->api)) {
                header_send("content-type", "application/json");
                $this->api['out'] = $this->out;
                $this->api['errors'] = $this->errors;
                if (count($this->errors) > 0) { $this->api['success'] = false; }
                $api_out = en_json($this->api);
                trace("API SZ:" . strlen($api_out));
                if (!$api_out) {
                    debug("unable to encode api response: [%d] (%s)", json_last_error(), json_last_error_msg());
                }
                echo $api_out;
            }
            // standard output
            else if (strlen($this->out) > 0) {
                echo $this->out;
            }
        }

        // note any errors...
        if (!empty($this->errors)) {
            $error_json = json_encode($this->errors);
            debug("ERROR effect: %s", $error_json);
            user_error("Effect errors: $error_json");
        } 

        // only do HTTP headers if not CLI
        if (php_sapi_name() != 'cli') {
            // http response
            if ($this->response > 0) {
                trace("CODE {$this->response}");
                http_response_code($this->response);
            }

            // cookies
            if (CFG::enabled('cookies_enabled') && !empty($this->cookie)) {
                if (!headers_sent($header_file, $line)) {
                    // remove any previously set cookie header
                    cookie('_bitf', $this->cookie, HOUR);
                } else {
                    $this->errors[] = "cookie headers already sent {$header_file}:{$line}";
                }
            }
           // only exit if we are running in the web context
            if ($this->exit) {
                if (isset($GLOBALS['bf_s1'])) {
                    $GLOBALS['bf_t1'] = ($GLOBALS['bf_t1']??0) + ((hrtime(true) - $GLOBALS['bf_s1']) / 1e+6);
                } else if (!isset($GLOBALS['bf_t1'])) {
                    $GLOBALS['bf_t1'] = 0.0;
                }
                $err_h = set_error_handler(null);
                if (empty($err_h)) {
                    debug('self exit complete [%.2fms] (%s) [%s]', ($GLOBALS['bf_t1']), $err_h, trace());
                }
                exit();
            }
        }

        return $this;
    }

    // return the number of errors occurred after a run(). should return 0
    public function num_errors() : int {
        return count($this->errors);
    }
}
Effect::$NULL = Effect::create();

// https://stackoverflow.com/questions/5707806/
function recursive_copy(string $source, string $dest) {
    mkdir($dest, 0755);

    /** @var \RecursiveDirectoryIterator $iterator */
    foreach ($iterator = new \RecursiveIteratorIterator(
    new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
    \RecursiveIteratorIterator::SELF_FIRST) as $item) {
        if ($item->isDir()) {
            mkdir($dest . DIRECTORY_SEPARATOR . $iterator->getSubPathname());
        } else {
            copy($item, $dest . DIRECTORY_SEPARATOR . $iterator->getSubPathname());
        }
    }
}

// https://stackoverflow.com/questions/3338123/
function recursive_delete(string $path): bool {
    $sep = DIRECTORY_SEPARATOR;
    $path = rtrim($path, $sep);

    // allow deleting a single file no symlink
    if (file_exists($path) && is_file($path) && !is_link($path)) {
        if (ends_with($path, '.php')) {
            // clear opcache if present
            if (function_exists('opcache_invalidate')) {
                @opcache_invalidate($path, true);
            }
        }
        return @unlink($path);
    }

    if (!is_dir($path)) {
        // nothing to delete
        return true;
    }

    $entries = scandir($path, SCANDIR_SORT_NONE);
    if ($entries === false) {
        return false;
    }

    $ok = true;
    foreach ($entries as $name) {
        if ($name === '.' || $name === '..') {
            continue;
        }
        $full = $path . $sep . $name;

        // do not follow symlinks to directories
        if (is_dir($full) && !is_link($full)) {
            $ok = recursive_delete($full) && $ok;
        } else if (!is_link($full)) {
            $ok = @unlink($full) && $ok;
            // clear the php cache if it is a php file
            if (ends_with($full, '.php')) {
                // clear opcache if present
                if (function_exists('opcache_invalidate')) {
                    @opcache_invalidate($full, true);
                }
            }
        }
    }

    return @rmdir($path) && $ok;
}


interface MaybeI {
    public static function of($x) : MaybeI;
    /**
     * call $fn (which has an external effect) on the value if it is not empty
     */
    public function effect(callable $fn) : MaybeI;
    public function then(callable $fn, bool $spread = false) : MaybeI;
    public function map(callable $fn) : MaybeI;
    public function keep_if(callable $fn) : MaybeI;
    /** execute $fn runs if maybe is not empty */
    public function do(callable $fn, ...$args) : MaybeI;
    /** execute $fn runs if maybe is empty */
    public function do_if_not(callable $fn, ...$args) : MaybeI;
    public function empty() : bool;
    public function set_if_empty($value) : MaybeI;
    public function errors() : array;
    public function value(?string $type = null);
    public function append($value) : MaybeI;
    public function size() : int;
    public function extract(string $key, $default = false) : MaybeI;
    public function index(int $index) : MaybeI;
    public function isa(string $type) : bool;
    public function __toString();
    public function __isset($object) : bool;
}


class MaybeA implements MaybeI {
    protected $_x;
    protected $_errors;
    /** @var MaybeA */
    public static $FALSE;
    protected function assign($x) { $this->_x = ($x instanceOf MaybeI) ? $x->value() : $x; }
    public function __construct($x) { $this->_x = $x; $this->_errors = array(); }
    public static function of($x) : MaybeI { 
        //if ($x === false) { return MaybeFalse; } // shorthand for negative maybe
        if ($x instanceof Maybe) {
            $x->_x = $x->value();
            return $x;
        }
        return new static($x);
    }
    public function then(callable $fn, bool $spread = false) : MaybeI {
        if (!empty($this->_x)) {
            $this->assign(
                ($spread) ?
                $fn(...$this->_x) :
                $fn($this->_x)
            );
            if (empty($this->_x)) { $this->_errors[] = func_name($fn) . ", created null [" . var_export($this->_x, true) . "]"; }
        } else {
            $this->_errors[] = func_name($fn) . ", [" . var_export($this->_x, true) . "]";
        }

        return $this;
    }
    public function map(callable $fn) : MaybeI { 
        if (is_array($this->_x) && !empty($this->_x)) {
            $this->_x = array_map($fn, $this->_x);
            if (empty($this->_x)) { $this->_errors[] = func_name($fn) . ", created null [" . var_export($this->_x, true) . "]"; }
        } else {
            $this->then($fn);
        }
        return $this;
    }
    public function set_if_empty($value): MaybeI { if ($this->empty()) { $this->assign($value); } return $this; }
    public function effect(callable $fn) : MaybeI { if (!empty($this->_x)) { $fn($this->_x); } else { 
        $this->_errors[] = func_name($fn) . ", null effect! [" . var_export($this->_x, true) . "]";
    } return $this; }
    public function keep_if(callable $fn) : MaybeI { if (!empty($this->_x) && $fn($this->_x) === false) { $this->_errors[] = func_name($fn) . " if failed"; $this->_x = NULL; } return $this; }
    /** execute $fn runs if maybe is not empty */
    public function do(callable $fn, ...$args) : MaybeI { if (!empty($this->_x)) { $this->assign($fn(...$args)); } else { 
        $this->_errors[] = func_name($fn) . ", null effect! [" . var_export($this->_x, true) . "]";
    } return $this; }
    /** execute $fn runs if maybe is empty */
    public function do_if_not(callable $fn, ...$args) : MaybeI { if (empty($this->_x)) { $this->assign($fn(...$args)); } return $this; }
    public function empty() : bool { return empty($this->_x); } // false = true
    public function errors() : array { return $this->_errors; }
    public function value(?string $type = null) { 
        $result = $this->_x;

        switch($type) {
            case 'str':
            case 'string':
                if (empty($this->_x)) { return ""; }
                $result = strval($this->_x);
                break;
            case 'int':
                if (empty($this->_x)) { return 0; }
                $result = intval($this->_x);
                break;
            case 'array':
                if (empty($this->_x)) { return []; }
                $result = is_array($this->_x) ? $this->_x : ((empty($this->_x)) ? array() : array($this->_x));
                break;
            case 'bool':
                if (empty($this->_x)) { return false; }
                return (bool)$this->_x;
        }
        return $result;
    }
    public function append($value) : MaybeI { $this->_x = (is_array($this->_x)) ? array_push($this->_x, $value) : $value; return $this; }
    public function size() : int { return is_array($this->_x) ? count($this->_x) : ((empty($this->_x)) ? 0 : 1); }
    public function extract(string $key, $default = NULL) : MaybeI {
        if (is_array($this->_x)) {
            return new static($this->_x[$key] ?? $default);
        } else if (is_object($this->_x)) {
            return new static($this->_x->$key ?? $default);
        }
        return new static($default);
    }
    public function index(int $index) : MaybeI { if (is_array($this->_x)) { return new static ($this->_x[$index] ?? NULL); } return new static(NULL); }
    public function isa(string $type) : bool { return $this->_x instanceof $type; }
    public function __toString() { return is_array($this->_x) ? $this->_x : (string)$this->_x; }
    public function __isset($object) : bool { debug("isset"); if ($object instanceof MaybeA) { return (bool)$object->empty(); } return false; }
    public function __invoke(?string $type = null) { return $this->value($type); }
}
class Maybe extends MaybeA {
    public function __invoke(?string $type = null) { return $this->value($type); }
}
class MaybeBlock extends MaybeA {
    public function __invoke(?string $type = null) { return $this->_x; }
}
class MaybeStr extends MaybeA {
    public function __invoke(?string $type = null) { if (empty($this->_x)) { return ""; } return is_array($this->_x) ? $this->_x : (string)$this->_x; }
    public function compare(string $test) : bool { return (!empty($this->_x)) ? $this->_x == $test : false; }
}
Maybe::$FALSE = MaybeBlock::of(NULL);


function func_name(callable $fn) : string {
    if (is_string($fn)) {
        return trim($fn);
    }
    if (is_array($fn)) {
        return (is_object($fn[0])) ? get_class($fn[0]) : trim($fn[0]) . "::" . trim($fn[1]);
    }
    return ($fn instanceof \Closure) ? 'closure' : 'unknown';
}


// web-filter only
function recache2(string $in) : array {
    //trace("RC".strlen($in));
    $key = CFG::str("encryption_key");
    $path = explode("\n", decrypt_ssl(sha1($key), $in)());
    //trace("DE".count($path));
    $foo = array_reduce($path, function ($carry, $x) {
        if (!isset($carry['tmp'])) { $carry['tmp'] = $x; }
        else { $carry[$x] = $carry['tmp']; unset($carry['tmp']); }
        return $carry;
    }, array());
    if (empty($foo)) { return []; }
    unset($foo['tmp']);
    return $foo;
}

function recache2_file(string $filename) : array {
    if (!file_exists($filename)) { trace("rc2[$filename]"); return []; }
    $content = file_get_contents($filename);
    if (empty($content)) { trace("rc2[$filename]"); return []; }
    return recache2($content);
}



/**
 * Encrypt string using openSSL module
 * @param string $text the message to encrypt
 * @param string $password the password to encrypt with
 * @return string message.iv
 */
function encrypt_ssl(string $password, string $text) : string {
    if (function_exists('openssl_encrypt')) {
        $iv = random_str(16);
        return openssl_encrypt($text, 'AES-128-CBC', $password, 0, $iv) . "." . $iv;
    }
    return base64_encode($text);
}

/**
 * aes-128-cbc decryption of data, return raw value
 * PURE
 */ 
function raw_decrypt(string $cipher, string $iv, string $password) : string {
    if (function_exists('openssl_decrypt')) {
        return openssl_decrypt($cipher, 'AES-128-CBC', $password, 0, $iv);
    }
    return base64_decode($cipher);
}

/**
 * Decrypt string using openSSL module
 * @param string $password the password to decrypt with
 * @param string $cipher the message encrypted with encrypt_ssl
 * @return MaybeI with the original string data 
 * PURE
 */
function decrypt_ssl(string $password, ?string $cipher) : MaybeI {
    // assert($password && strlen($password) >= 8, "password must be at least 8 characters");
    if (empty($cipher) || strlen($cipher) < 8) { 
        debug_print_backtrace();
        debug("wont decrypt with no encryption data");
        return MaybeStr::of(NULL);
    }

    $decrypt_fn = ƒixr("ThreadFin\\raw_decrypt", $password);

    $a = MaybeStr::of($cipher)
        ->then(ƒixl("explode", "."))
        ->keep_if(fn($x) => count($x) == 2)
        ->then($decrypt_fn, true);
    return $a;
}


/**
 * calls $carry $fn($key, $value, $carry) for each element in $map
 * allows passing optional initial $carry, defaults to empty string
 * PURE as $fn, returns $carry
 */
function map_reduce(array $map, callable $fn, $carry = "") {
    foreach($map as $key => $value) { $carry = $fn($key, $value, $carry); }
    return $carry;
}


/**
 * map each value in $map to $fn($value) and return the result. 
 * if $fn($value) returns NULL, the key is not added to the result
 */
function map_map_value(?array $map, callable $fn) : array {
    $result = [];
    if (empty($map)) { return $result; }

    foreach($map as $key => $value) {
        $tmp = $fn($value);
        if ($tmp !== NULL) {
            $result[(string)$key] = $tmp;
        }
    }
    return $result;
}


//  calls $fn($key, $value) on $data, return value will be a list, not a map
function array_map_value(callable $fn, array $data) : array {
    return array_map($fn, array_keys($data), array_values($data));
}


/**
 * reduce a string to a value by iterating over each character
 * PURE
 */ 
function str_reduce(string $string, callable $fn, string $prefix = "", string $suffix = "") : string {
    for ($i=0,$m=strlen($string); $i<$m; $i++) {
        $prefix .= $fn($string[$i]);
    }
    return $prefix . $suffix;
}


/**
 * match address 
 * @return bool 
 */
function cidr_match(string $address, string $subnetAddress, int $subnetMask) : bool {
    static $cache = [];
    $address = $cache[$address]??ip2long($address);
    $subnetAddress = $cache[$address]??ip2long($subnetAddress);
    $mask = -1 << (32 - $subnetMask);
    $subnetAddress &= $mask; // subnet wasn't correctly aligned
    $r = ($address & $mask);
    return (($r) == $subnetAddress) && $subnetMask >= 0;
}


// write debug message return NULL
function debugN(string $fmt, ...$args) : ?bool {
    debug($fmt, ...$args);
    return NULL;
}

// write debug message and return 0
function debugZ(string $fmt, ...$args) : int {
    debug($fmt, ...$args);
    return 0;
}

// write debug message and return false
function debugF(string $fmt, ...$args) : bool {
    debug($fmt, ...$args);
    return false;
}


// allow trace to be over ridden
function trace(?string $msg = null, bool $clear = false) : string {
    static $r = "";
    if ($msg == null) {
        if ($clear) {
            $r = "";
        }
        return $r;
    }
    $r .= "$msg, ";
    return "";
}

/**
 * call the error handler.  This will create at most 1 new error entry in errors.json
 * @param null|string $fmt 
 * @param mixed $args 
 * @return void 
 * @throws RuntimeException 
 */
function error(?string $fmt, ...$args) : void {
    debug($fmt, ...$args);
    $line = str_replace(array("\r","\n",":"), array(" "," ",";"), sprintf($fmt, ...$args));
    $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
    $idx = isset($bt[1]) ? 1 : 0;
    \BitFire\on_err(-1, $line, $bt[$idx]["file"], $bt[$idx]["line"]);
    if (isset($bt[2])) {
        \BitFire\on_err(2, $line, $bt[2]["file"], $bt[2]["line"]);
    }
}

function format_chk(?string $fmt, int $args) : bool {
    if ($fmt == null) { return true; }
    return(substr_count($fmt, "%") === $args);
}

/**
 * add a line to the debug file (SLOW, does not wait until processing is complete)
 * NOT PURE
 */
function debug(?string $fmt, ...$args) : ?array {
    assert(class_exists('\BitFire\Config'), "programmer error, call debug() before config is loaded");
    assert(format_chk($fmt, count($args)), "programmer error, format string does not match number of arguments [$fmt]");

    static $idx = 0;
    static $len = 0;
    static $log = [];
    static $early_exit = -1; 

    // format any objects or arrays for debug
    foreach ($args as &$arg) { 
        if (is_array($arg) || is_object($arg)) { $arg = json_encode($arg, JSON_PRETTY_PRINT); }
        // else { $arg = str_replace("%", "%%", $arg); }
    }


    // first call, figure out if we are exiting early. this executes 1 time
    if ($early_exit === -1) {
        $early_exit = (!CFG::enabled("debug_file") && !CFG::enabled("debug_header")) ? 1 : 0;
    }
    // if we are not debugging, return early
    if ($early_exit === 1 || empty($fmt)) {
        return (empty($fmt)) ? $log : null;
    }

    // ugly AF
    if ($fmt === null) { return $log; }

    $line = "";
    // write debug to headers for quick debug
    if (CFG::enabled("debug_header") && function_exists('headers_sent')) {
        $line = str_replace(array("\r","\n",":"), array(" "," ",";"), @sprintf($fmt, ...$args));
        if (!headers_sent() && $idx < 24) {
            $s = substr($line, 0, 1024);
            $len += strlen($s);
            if ($len < 3000) {
                header_send("x-bf-$idx", $s);
            }
        }
    }

    // write to file
    if (CFG::enabled("debug_file")) {
        if ($idx === 0) {
            register_shutdown_function(function () {
                $f = get_hidden_file("/debug.log");
                $log = debug(null);
                $mode = (file_exists($f) && filesize($f) > 1024*1024*8) ? LOCK_EX : LOCK_EX | FILE_APPEND;
                file_put_contents($f, join("\n", $log), $mode);
            });
        }
        $line = sprintf($fmt, ...$args);
        if (starts_with($fmt, "ERROR")) {
            $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
            $b1 = isset($bt[2]) ? ($bt[2]['file']??'??'.':'.$bt[2]['line']??'??') : 'no-bt2';
            $b2 = isset($bt[3]) ? ($bt[3]['file']??'??'.':'.$bt[3]['line']??'??') : 'no-bt3';
            $line = "$line\nB1: $b1\nB2: $b2";
        }
    }
    
    $idx++;
    if (!empty($line)) { $log[] = $line; }
    return null;
}




/**
 * sets a cookie in a browser in various versions of PHP
 * NOT PURE 
 */
function cookie(string $name, ?string $value, int $exp = DAY) : void {
    if (!CFG::enabled("cookies_enabled")) { debug("wont set cookie, disabled"); return; }
    if (!function_exists('headers_sent')) { trace("CLI no cookies"); return; }
    if ($value != '1' && $value != '3') { // always set a javascript client side cookie!
        if (count($_COOKIE) == 0) { debug('no cookie support'); return; }
        if (!isset($_COOKIE['wordpress_test_cookie'])) { debug('cookie not logged in'); return; }
    }

    $value = (empty($value)) ? '' : $value;
    if (headers_sent($file, $line)) { debug("unable to set cookie, headers already sent (%s:%d)", $file, $line); return; }
    if (PHP_VERSION_ID < 70300) { 
        setcookie($name, $value, time() + $exp, '/; samesite=strict', '', false, false);
    } else {
        setcookie($name, $value, [
            'expires' => time() + $exp,
            'path' => '/',
            'domain' => '',
            'secure' => false,
            'httponly' => false,
            'samesite' => 'strict'
        ]);
    }
}


/**
 * replace file contents inline, $find can be a regex or string
 */
function file_replace(string $filename, string $find, string $replace, int $mode = 0) : Effect {
    $fn_name = ($find[0] == "/") ? "preg_replace" : "str_replace";
    $fn = partial($fn_name, $find, $replace);

    $file_mod = FileData::create($filename)->read()->map($fn)->file_mod($mode);
    return Effect::create()->file($file_mod);
}


/**
 * return an effect to create a ini_info.php file which sets
 * a variable $ini_type to the type of ini file used. we do
 * this here because some wordpress servers do not always
 * allow us to write php files on any request.
 * @return Effect 
 */
function make_config_loader() : Effect {
    $effect = Effect::create();
    static $path = "";
    if (!empty($path)) { return $effect->out($path)->hide_output(); }

    $content_dir = (defined('WP_CONTENT_DIR') && !empty(WP_CONTENT_DIR)) ? WP_CONTENT_DIR : dirname(WAF_ROOT, 2);

    // normal path, load the secret key are return the path to the config file
    $file = \BitFire\WAF_ROOT."ini_info.php";
    $secret_key = "";
    if (file_exists($file)) {
        // including the ini_info.php file will define $secret_key and $ini_type
        include $file;

        $dir_path = $content_dir . DIRECTORY_SEPARATOR . "uploads/bitfire_{$secret_key}";
        $orig_path = WAF_ROOT . "orig_config";
        if (!file_exists($dir_path) && file_exists($orig_path)) {
            $scrubbed = substr($secret_key, 0, 4);
            debug("moving original config to uploads/bitfire_{$scrubbed}");
            rename($orig_path, $dir_path);
            // let the file system sync
            sleep(1);
        }
        // try json config first
        $config_path = "$dir_path/config.json";
        $config_file = realpath($config_path);
        // normal case after secret directory move
        if ($config_file) {
            $path = $config_file;
            return $effect->out($config_file)->hide_output();
        }
        // fall back to ini config
        $config_path = "$dir_path/config.ini";
        $config_file = realpath($config_path);
        // normal case after secret directory move
        if ($config_file) {
            $path = $config_file;
            return $effect->out($config_file)->hide_output();
        }
    }

    // make sure we have the server modification functions available and migrate an old config
    require_once WAF_ROOT . "/src/server.php";

    // perform location migration
    if (function_exists('\BitFireSvr\migrate_config_dir')) {
        $new_key = \BitFireSvr\migrate_config_dir($secret_key);
        $config_path = $content_dir . DIRECTORY_SEPARATOR . "uploads/bitfire_{$new_key}/config.ini";

        // we should have a valid config at this location now
        $config_file = realpath($config_path);

        // normal case after secret directory move
        if ($config_file) {
            $path = $config_file;
            return $effect->out($config_file)->hide_output();
        }
    }

    // something has gone very wrong at this point. (probably file permissions)
    // return the default config file
    $hidden_config = WAF_ROOT . "hidden_config/config.ini";
    $orig_config = WAF_ROOT . "orig_config/config.ini";
    $path = '';
    if (file_exists($hidden_config)) { $path = $hidden_config; }
    else if (file_exists($orig_config)) { $path = $orig_config; }
    $hidden_config = WAF_ROOT . "hidden_config/config.json";
    $orig_config = WAF_ROOT . "orig_config/config.json";
    if (file_exists($hidden_config)) { $path = $hidden_config; }
    else if (file_exists($orig_config)) { $path = $orig_config; }
    return $effect->out($path)->hide_output();
}

/**
 * create an effect to write the ini_info.php file, will auto determine the cache type if one is not given
 * @param string $secret_key 
 * @param string $ini_type 
 * @return Effect 
 */
function make_ini_info(string $secret_key, string $ini_type = '') : Effect {
    $effect = Effect::create();
    // attempt to use shared memory if available
    if (empty($ini_type)) {
        if (function_exists("shmop_open")) {
            $id = @shmop_open(12345, 'c', 0666, 1470008);
            if (!empty($id)) {
                @shmop_delete($id); 
                $ini_type = "shmop";
            }
        }
    }
    // if we don't have shmop, then we use opcache
    if (empty($ini_type)) {
        $ini_type = "opcache";
    }
    // if we have a secret key, then we write the ini_info.php file
    $markup = "<?php \$secret_key = '$secret_key'; \$ini_type = '$ini_type';";
    $effect->file(new FileMod(\BitFire\WAF_ROOT."ini_info.php", $markup));

    return $effect;
}




/**
 * get the path to a hidden file
 * @param string $file_name the name of the file
 * @param (null|string)|null $secret_key - the secret key as stored in the ini_info.php file
 * @return string - the realpath to the file
 */
function get_hidden_file(string $file_name) : string {
    static $path = null;
    //if (php_sapi_name() === "cli") { return getcwd() . "/$file_name"; }

    if (defined("BITFIRE_TEST_PATH")) {
        $path = realpath(\BITFIRE_TEST_PATH) . DIRECTORY_SEPARATOR;
    }

    // fall back to the secret key in the ini_info file
    if (empty($path)) {
        // make config loader returns the path to the config file
        $config_file = make_config_loader()->run()->read_out();
        // get the path to the hidden directory 
        $test = dirname($config_file);
        // we found the config, save that path in the static variable for future use
        if (file_exists($test) && WAF_ROOT != $test) {
            $path = $test . DIRECTORY_SEPARATOR;
        }

        $config_file = dirname($test) . DIRECTORY_SEPARATOR . "index.php"; 
        // make sure that the index.php file exists in the wp-content/uploads directory to hide the configuration
        if (!file_exists($config_file)) {
            file_put_contents($config_file, "<?php\n//Silence is golden.", LOCK_EX);
        }

    }
    // very odd case (SHOULD NEVER HAPPEN) where the config file is not found, we use the default path
    if (empty($path)) {
        $path = WAF_ROOT;
    }
    // if we are requesting an empty file name, we just return the path (since they are requesting the hidden directory path)
    if ($file_name == "") {
        return $path;
    }

    $file_path = $path . $file_name;
    return $file_path; // hold on to your butts!
}

/**
 * a fail safe fall back config if we can't find one
 * @return array 
 */
function default_config() : array {
    return [
        "bitfire_enabled" => false,
        "max_rr" => 0,
        "security_headers_enabled" => false,
        "log_everything" => false,
        "web_filter_enabled" => false,
        "require_full_browser" => false,
        "whitelist_enable" => false,
        "blacklist_enable" => false,
        "cache_type" => "nop",
        "ip_header" => "remote_addr",
        "default_config" => true,
        "wizard" => true
    ];
}



function convert_ini_to_json(string $config_file, array $config) : Effect {
    $effect    = Effect::create();
    $info      = pathinfo($config_file);
    $json_path = $info['dirname'] . DIRECTORY_SEPARATOR . $info['filename'] . ".json";

    $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    if ($json === false) {
        // Failsafe: don’t try to write invalid JSON
        // (caller already assumes Effect works; we just avoid writing garbage)
        return $effect;
    }
    $effect->file(new FileMod($json_path, $json, 0, 0, false, true));
    // remove the original ini file
    if ($info['extension'] == 'ini') {
        $effect->unlink($config_file);
    }
    return $effect;
}


function recover_config(string $config_path) : array {
    $config_dir = dirname($config_path);
    $sample = $config_dir . DIRECTORY_SEPARATOR . "config-sample.ini";

    // try and recover a default config
    if (file_exists($sample)) {
        // If copy fails, fall back to default
        if (@copy($sample, $config_path)) {
            $config = @parse_ini_file($config_path, false, INI_SCANNER_TYPED);
            if (is_array($config) && count($config) > 20) {
                return $config;
            }
        }
    }
    // failsafe config so we don't crash later
    return default_config();
}



/**
 * load the config from the secret config location
 * @return array 
 * @throws RuntimeException 
 */
function parse_ini() : array {

    $config_file = make_config_loader()->run()->read_out();
    $cache_config_file = $config_file . ".php";

    // return a core config with everything off if the config file is not found...
    // things are bad if we hit this...
    if (!file_exists($config_file)) {
        return recover_config($config_file);
    }

    // get the ini file modification time
    $cache_mod_time = $mod_time = 0;
    $exists   =  file_exists($cache_config_file);
    if ($exists) {
        $mod_time = @filemtime($config_file);
        $cache_mod_time = @filemtime($cache_config_file);
    }
    // if the file modification time is newer than the cached data, reload the config
    if (!$exists || $cache_mod_time < $mod_time) {
        if (!headers_sent()) {
            header("X-config: reload");
        }

        // support for json configs...
        $ext = strtolower(pathinfo($config_file, PATHINFO_EXTENSION));
        if ($ext === "json") {
            $raw = file_get_contents($config_file);
            $config = is_string($raw) ? json_decode($raw, true) : null;
        }
        // we have an old config, lets upgrade it!
        else {
            $config = parse_ini_file($config_file, false, INI_SCANNER_TYPED);
        }

        // Validate
        if (!is_array($config) || count($config) <= 20) {
            return recover_config($config_file);
        }

        // don't allow the server to pick a cache type that is not available
        $have_shmop = function_exists("shmop_open");
        if ((($config['cache_type']??'nop') == "shmop") && !$have_shmop) {
            $config['cache_type'] = "opcache";
        }

        $s = var_export($config, true);
        $priority = "1";
        $exp = time() + 86400*7; 
        $data = "<?php \$value = $s; \$priority = $priority; \$success = (time() < $exp);";
        // if we can't write the cache file, at least return a sane config
        file_put_contents($cache_config_file, $data, LOCK_EX);
        // return the config we parsed and have now cached.
        clearstatcache(true, $cache_config_file);
        return $config;
    }
        
    if (file_exists($cache_config_file)) {
        include $cache_config_file;
        // normal case, we have a valid config
        if (isset($value) && is_array($value) && count($value) > 20) {
            $config = $value;
            // the cached config is expired, just remove it so it will regenerate
            if (!$success) {
                @unlink($cache_config_file);
            }
        } else {
            // the cached config file is invalid, just remove it so it is auto generated
            @unlink($cache_config_file);
            clearstatcache(true, $cache_config_file);
            return recover_config($config_file);
        }
    }

    // Fallback: if we got here without a cache, return a direct parse
    // (this path happens only if we early-returned after write failure)
    if (isset($config) && is_array($config)) {

        // write the migrated config file with any runtime modifications we made
        if (ends_with($config_file, ".ini")) {  
            $effect = convert_ini_to_json($config_file, $config);
            $effect->run();
        }


        return $config;
    }
    return recover_config($config_file);
}



/**
 * impure fetch pro code and install. This should return an effect...
 * 
 * @param string $pro_key 
 */
function check_pro_ver(string $pro_key) {
    // pro key and no pro files, download them UGLY, clean this!
    if (!is_writable(\BitFire\WAF_SRC)) {
        // TODO: add WordPress notice from here....
        //debug("unable to write PRO file to [%s]", \BitFire\WAF_SRC);
        return;
    }

    $profile = \BitFire\WAF_SRC . "proapi.php";
    if (strlen($pro_key) > 20 && (!file_exists($profile) || 
        (file_exists($profile) && ((@filesize($profile) < 5000)) || filemtime($profile) < (time() - (\ThreadFin\DAY * 3))))) {
        //trace("DOWN_PRO");
        $email = "unknown";
        $name = "unknown";
        if (defined("WPINC") && function_exists("wp_get_current_user")) {
            $user = \wp_get_current_user();
            $name = $user->user_firstname . " " . $user->user_lastname;
        }
        $out = \BitFire\WAF_SRC."pro.php";
        $content = http("POST", "https://www.bitfire.co/getpro.php", array("name" => $name, "email" => $email, "release" => \BitFire\BITFIRE_VER, "key" => $pro_key, "domain" => $_SERVER['SERVER_NAME'], "filename" => "pro.php"));
        if ($content && strlen($content->content) > 5000) {
            if (@file_put_contents($out, $content->content, LOCK_EX) !== strlen($content->content)) { debug("unable to write [%s]", $out); };
            $content = http("POST", "https://www.bitfire.co/getpro.php", array("name" => $name, "email" => $email, "release" => \BitFire\BITFIRE_VER, "key" => $pro_key, "domain" => $_SERVER['SERVER_NAME'], "filename" => "proapi.php"));
            $out = \BitFire\WAF_SRC."proapi.php";
            if ($content && strlen($content->content) > 5000) {
                if (@file_put_contents($out, $content->content, LOCK_EX) !== strlen($content->content)) { debug("unable to write [%s]", $out); };
            }

            require_once WAF_SRC . "server.php";
            update_ini_value("dynamic_exceptions", time() + (DAY * 3))->run();
        }
    }
}


/**
 * effect with cache prevention headers
 * PURE: IDEMPOTENT, REFERENTIAL INTEGRITY
 */
function cache_prevent(?Effect $effect = null) : Effect {
    if ($effect == null) {
        $effect = new Effect();
    }
    $effect->header("cache-control", "no-store, private, no-cache, max-age=0");
    $effect->header("expires", gmdate('D, d M Y H:i:s \G\M\T', 100000));
    $effect->header("vary", "*");
    return $effect;
}


// return date in utc time
function utc_date(string $format) : string {
    return date($format, utc_time());
}

function utc_time() : int {
    return time() + date('Z');
}


// only used in bot-filter
function array_shuffle(array $in) : array {
    $out = array();
    while(($m = count($in))>0) {
        $t = array_splice($in, mt_rand(0, $m) , 1);
        $out[] = $t[0]??0;
    }
    return $out;
}

/**
 * returns a maybe with tracking data or an empty monad...
 * PURE!
 */
function decrypt_tracking_cookie(?string $cookie_data, string $encrypt_key, string $src_ip, string $agent) : MaybeI {
    static $r = null;
    // don't bother decrypting if we have no cookie data
    if (empty($cookie_data)) { return MaybeStr::of(false); }
    if ($r === null) { $r = MaybeStr::of(false); }

    $r->do_if_not(function() use ($cookie_data, $encrypt_key, $src_ip, $agent) {

        return decrypt_ssl($encrypt_key, $cookie_data)
            ->then("ThreadFin\\un_json")
            ->keep_if(function($cookie) use ($src_ip, $agent) {
                if (!isset($cookie['wp']) && !isset($cookie['ip']) && !isset($cookie['lck']) && !isset($cookie['mfa'])) {
                    debug("invalid decrypted cookie [%s] ", var_export($cookie, true));
                    return false;
                } else if (isset($cookie['ip'])) {
                    $src_ip_crc = crc32($src_ip);
                    $cookie_match = (is_array($cookie) && (intval($cookie['ip']??0) == intval($src_ip_crc)));
                    $time_good = ((intval($cookie['et']??0)) > time());
                    $agent_good = crc32($agent) == $cookie['ua'];
                    if (!$cookie_match) { debug("cookie ip does not match"); }
                    if (!$time_good) { debug("cookie expired"); }
                    if (!$agent_good) { debug("agent mismatch live: [%s] [%d] cookie:[%d]", $agent, crc32($agent), $cookie['ua']??0); }
                    return ($cookie_match && $time_good && $agent_good);
                } else { return true; }
            });
    });
    return $r;
}



/**
 * @depends CFG:cms_root, cms_content_dir, cms_content_url, _SERVER: DOCUMENT_ROOT
 * @return string URL path to the public folder
 */
function get_public(?string $path = null) : string {
    // try and find the path to the public folder ourself (for standalone installs)
    $public = realpath(__DIR__ . "/../public/$path").DS;
    $dr = realpath($_SERVER['DOCUMENT_ROOT']??".");
    $public = str_replace($dr, "", $public);
    if ($path !== null) { $public = rtrim($public, "/"); }
    // if we have a cms configuration, use that
    if (CFG::enabled("cms_root")) {
        $path = ($path === null) ? "" : $path;
        if (file_exists(CFG::str("cms_content_dir") . "/plugins/bitfire/public/$path")) {
            $public = CFG::str("cms_content_url")."/plugins/bitfire/public/$path";
        }
    }
    return $public;
}

function _b(string $text, $before = "") : string {
    return (string)$before . _($text);
}

function _t(string $text) : string {
    return $text;
}


/**
 * @param mixed $data 
 * @return array [compressed_data, uncompressed_size, type]
 */
function compress($data) : array {
    $type = "serialize";
    // we have a header we can write cache data to...
    if (function_exists('\igbinary_serialize')) {
        $compress = \igbinary_serialize($data);
        $type = "igbinary";
    } else if (function_exists('\msgpack_unpack')) {
        $compress = \msgpack_pack($data);
        $type = "msgpack";
    } else {
        $compress = serialize($data);
    }
    return [base64_encode($compress), strlen($data), $type];
}


/**
 * return original data from compressed data
 * @param array $data 
 * @return mixed 
 */
function uncompress(array $data) {
    $compress = base64_decode($data[0]);
    switch ($data[2]) {
        case "igbinary":
            return \igbinary_unserialize($compress);
        case "msgpack":
            return \msgpack_unpack($compress);
        default:
            return unserialize($compress);
    }
}


class Entity implements ArrayAccess {
    public function __construct() { }

    public function offsetExists($offset): bool {
        return isset($this->$offset);
    }

    #[\ReturnTypeWillChange]
    public function offsetGet($offset) {
        return $this->$offset;
    }

    public function offsetSet($offset, $value): void {
        $this->$offset = $value;
    }

    public function offsetUnset($offset): void {
        unset ($this->$offset);
    }

    /**
     * take a string of JSON and return an entity of this class
     * @param string $json 
     * @throws InvalidArgumentException is json is invalid
     * @return static 
     */
    public static function from_json(string $json, string $root = "") {
        $data = json_decode($json, false, 512, JSON_ERROR_SYNTAX);
        if ($data === NULL) {
            throw new InvalidArgumentException("error decoding json into " . static::class . " : " . json_last_error_msg());
        }
        return cast(static::class, $data, $root);
    }
}



function cast_assign(Entity $entity, stdClass $obj) {

    foreach ($obj as $key => $value) {
        if (is_array($value)) {
            $t = get_class($entity->$key);
            if (substr($t, -5) == "_List") {
                $class = substr($t, 0, -5);
                $entity->$key = new $t($value, $class);
                continue;
            }
        } else if (is_object($value)) {
            if (!isset($entity->$key)) {
                $rc = new ReflectionClass($entity);
                if (!$rc->hasProperty($key)) {
                    throw new InvalidArgumentException(get_class($entity) . " does not have property named [$key]");
                }
                $rp = $rc->getProperty($key);
                $t = (string)$rp->getType();
            } else {
                $t = get_class($entity->$key);
            }
            $obj = new $t();
            $entity->$key = cast_assign($obj, $value);
        } else {
            $entity->$key = $value;
        }
    }
    return $entity;
}

function cast(string $type, stdClass $obj, $root = "") {
    if ($root !== "") {
        return cast_assign(new $type(), $obj->$root);
    }
    return cast_assign(new $type(), $obj);
}

class Hash_Config {
    public string $hash;
    public int $valid_seconds;
    public int $trim_len;
    public function __construct($hash = "sha256", $valid_seconds = HOUR * 6, $trim_len = 99) {
        $this->hash = $hash;
        $this->valid_seconds = $valid_seconds;
        $this->trim_len = $trim_len;
    }
}

// create a new hmac code for validate_code
function make_code(string $secret, Hash_Config $config, int $time = 0) : string {
    $iv = strtolower(random_str(12));
    if ($time == 0) {
        $time = time();
    }
    $data = "$iv.$time";
    $h = hash_hmac($config->hash, $data, $secret, false);
    $hash = substr($h, 0, $config->trim_len);
    return "{$hash}.{$iv}.{$time}";
}

// map a plugin path (or partial path) to the slug
function path_to_slug(string $path) : string {
    // extract after "/plugins/"
    $needle = DS . 'plugins' . DS;
    $pos = strpos($path, $needle);
    if ($pos !== false) {
        $start = $pos + strlen($needle);
        // take chars until next '/'
        $len = strcspn($path, DIRECTORY_SEPARATOR, $start);
        return substr($path, $start, $len);
    }

    // fallback, filename without .php (case-insensitive), no regex
    $base = basename($path);
    if (strlen($base) > 4 && strcasecmp(substr($base, -4), '.php') === 0) {
        $base = substr($base, 0, -4);
    }
    return $base;
}

/**
 * 
 * @param string $test_hmac - the hmac to test
 * @param string $iv - the iv for the hash
 * @param string $time - time the test_hmac was generated
 * @param string $secret  - the shared secret key
 * @param Hash_Config $config 
 * @return bool 
 */
function validate_raw(string $test_hmac, string $iv, string $time, string $secret, Hash_Config $config) : bool {
    $MAX_SKEW = 3600; // 1 hour skew (very generous)

    // GUARDS ...
    assert(is_int($config->trim_len) && $config->trim_len > 0 && $config->trim_len < 128, "invalid trim length: " . $config->trim_len);
    assert(is_int($config->valid_seconds) && $config->valid_seconds > 0, "invalid valid_seconds value");
    if (empty($iv)) { return false; }

    // Time must be strictly digits (UNIX seconds)
    if (!ctype_digit($time)) { return false; }
    $t = intval($time);
    if ($t <= 0) { return false; } // disallow bypass

    // Expiry & clock skew
    $now = time();

    $diff = ($now - $t);
    if (($diff) > $config->valid_seconds) { return false; }


    $secret = strlen($secret) < 5 ? "default" : $secret;
    $data = ($t === 0) ? $iv : "{$iv}.{$time}";
    $h = hash_hmac($config->hash, $data, $secret, false);
    if ($h === false) { return false; } // should never happen, but make sure we can't type error next line
    $d3 = substr($h, 0, $config->trim_len);
    return hash_equals($d3, strtolower($test_hmac));
}



/**
 * Set a specific bit in the bitmap to 1.
 * @param string &$bitmap - The bitmap to modify. This will be modified in place.
 * @param int $bit - The bit index to set (0-based). Must be between 0 and BITMAP_BITS-1.
 * @return bool - Returns true if the bit in newly set , false if it is already set
 * @test - test_util.php
 */
function bit_set(string &$bitmap, int $bit): bool {
    $len = strlen($bitmap);
    $byte = intdiv($bit, 8);
    $offset = $bit % 8;

    // Pad string with nulls if necessary
    if ($len < $byte) {
        return false; // Invalid bit index
    }

    $bit = 1 << $offset;
    $check = @ord($bitmap[$byte]);
    if (($check & $bit)) {
        return false;
    }

    $bitmap[$byte] = chr($check | $bit);
    return true;
}

/**
 * Count the number of one bits in the bitmap.
 * @param string $bitmap - binary string
 * @return int - number of 1 bits (set bits)
 */
function bit_count_one_bits(string $bitmap): int {
    static $bitcount_table = null;

    // Precompute lookup table for byte values (0–255)
    if ($bitcount_table === null) {
        $bitcount_table = array_map(function ($b) {
            return substr_count(decbin($b), '1');
        }, range(0, 255));
    }

    $oneBits = 0;
    $len = strlen($bitmap);

    // Count the number of bits in each byte
    for ($i = 0; $i < $len; $i++) {
        $byte = ord($bitmap[$i]);
        $ones = $bitcount_table[$byte];
        $oneBits += $ones;
    }

    return $oneBits;
}



