<?php
/**
 * Modernized Sphinx Search API Client
 * 
 * Updated for PHP 8.x compatibility
 * @license GPL 2
 */

/// known searchd commands
const SEARCHD_COMMAND_SEARCH   = 0;
const SEARCHD_COMMAND_EXCERPT  = 1;
const SEARCHD_COMMAND_UPDATE   = 2;
const SEARCHD_COMMAND_KEYWORDS = 3;
const SEARCHD_COMMAND_PERSIST  = 4;
const SEARCHD_COMMAND_STATUS   = 5;
const SEARCHD_COMMAND_QUERY    = 6;

/// current client-side command implementation versions
const VER_COMMAND_SEARCH   = 0x116;
const VER_COMMAND_EXCERPT  = 0x100;
const VER_COMMAND_UPDATE   = 0x102;
const VER_COMMAND_KEYWORDS = 0x100;
const VER_COMMAND_STATUS   = 0x100;
const VER_COMMAND_QUERY    = 0x100;

/// known searchd status codes
const SEARCHD_OK      = 0;
const SEARCHD_ERROR   = 1;
const SEARCHD_RETRY   = 2;
const SEARCHD_WARNING = 3;

/// known match modes
const SPH_MATCH_ALL       = 0;
const SPH_MATCH_ANY       = 1;
const SPH_MATCH_PHRASE    = 2;
const SPH_MATCH_BOOLEAN   = 3;
const SPH_MATCH_EXTENDED  = 4;
const SPH_MATCH_FULLSCAN  = 5;
const SPH_MATCH_EXTENDED2 = 6;

/// known ranking modes
const SPH_RANK_PROXIMITY_BM25 = 0;
const SPH_RANK_BM25           = 1;
const SPH_RANK_NONE           = 2;
const SPH_RANK_WORDCOUNT      = 3;
const SPH_RANK_PROXIMITY      = 4;
const SPH_RANK_MATCHANY       = 5;
const SPH_RANK_FIELDMASK      = 6;

/// known sort modes
const SPH_SORT_RELEVANCE     = 0;
const SPH_SORT_ATTR_DESC     = 1;
const SPH_SORT_ATTR_ASC      = 2;
const SPH_SORT_TIME_SEGMENTS = 3;
const SPH_SORT_EXTENDED      = 4;
const SPH_SORT_EXPR          = 5;

/// known filter types
const SPH_FILTER_VALUES     = 0;
const SPH_FILTER_RANGE      = 1;
const SPH_FILTER_FLOATRANGE = 2;

/// known attribute types
const SPH_ATTR_INTEGER   = 1;
const SPH_ATTR_TIMESTAMP = 2;
const SPH_ATTR_ORDINAL   = 3;
const SPH_ATTR_BOOL      = 4;
const SPH_ATTR_FLOAT     = 5;
const SPH_ATTR_BIGINT    = 6;
const SPH_ATTR_MULTI     = 0x40000000;

/// known grouping functions
const SPH_GROUPBY_DAY      = 0;
const SPH_GROUPBY_WEEK     = 1;
const SPH_GROUPBY_MONTH    = 2;
const SPH_GROUPBY_YEAR     = 3;
const SPH_GROUPBY_ATTR     = 4;
const SPH_GROUPBY_ATTRPAIR = 5;

/**
 * 64-bit integer packing helpers
 */
function sphPackI64($v): string {
    $v = (string)$v;
    if (PHP_INT_SIZE >= 8) {
        $v = (int)$v;
        return pack("NN", $v >> 32, $v & 0xFFFFFFFF);
    }
    if (function_exists("bcmul")) {
        if (bccomp($v, "0") == -1) $v = bcadd("18446744073709551616", $v);
        $h = bcdiv($v, "4294967296", 0);
        $l = bcmod($v, "4294967296");
        return pack("NN", (float)$h, (float)$l);
    }
    return pack("NN", 0, (int)$v); // Fallback for simple ints
}

function sphPackU64($v): string {
    return sphPackI64($v);
}

function sphUnpackU64($v) {
    $data = unpack("Nhi/Nlo", $v);
    $hi = $data['hi'];
    $lo = $data['lo'];

    if (PHP_INT_SIZE >= 8) {
        if ($hi < 0) $hi += (1 << 32);
        if ($lo < 0) $lo += (1 << 32);
        return ($hi << 32) + $lo;
    }
    
    $hi = sprintf("%u", $hi);
    $lo = sprintf("%u", $lo);
    if (function_exists("bcmul")) {
        return bcadd($lo, bcmul($hi, "4294967296"));
    }
    return $lo; 
}

function sphUnpackI64($v) {
    return sphUnpackU64($v);
}

function sphFixUint($value) {
    if (PHP_INT_SIZE >= 8) {
        if ($value < 0) $value += (1 << 32);
        return $value;
    }
    return sprintf("%u", $value);
}

class SphinxClient {
    public $_host = "localhost";
    public $_port = 9312;
    public $_offset = 0;
    public $_limit = 20;
    public $_mode = SPH_MATCH_ALL;
    public $_weights = [];
    public $_sort = SPH_SORT_RELEVANCE;
    public $_sortby = "";
    public $_min_id = 0;
    public $_max_id = 0;
    public $_filters = [];
    public $_groupby = "";
    public $_groupfunc = SPH_GROUPBY_DAY;
    public $_groupsort = "@group desc";
    public $_groupdistinct = "";
    public $_maxmatches = 1000;
    public $_cutoff = 0;
    public $_retrycount = 0;
    public $_retrydelay = 0;
    public $_anchor = [];
    public $_indexweights = [];
    public $_ranker = SPH_RANK_PROXIMITY_BM25;
    public $_maxquerytime = 0;
    public $_fieldweights = [];
    public $_overrides = [];
    public $_select = "*";

    public $_error = "";
    public $_warning = "";
    public $_connerror = false;

    public $_reqs = [];
    public $_mbenc = "";
    public $_arrayresult = false;
    public $_timeout = 0;
    
    protected $_socket = false;
    protected $_path = false;

    public function __construct() {
        // Initialized via property defaults
    }

    public function __destruct() {
        if ($this->_socket !== false) {
            fclose($this->_socket);
        }
    }

    public function GetLastError(): string {
        return $this->_error;
    }

    public function GetLastWarning(): string {
        return $this->_warning;
    }

    public function IsConnectError(): bool {
        return $this->_connerror;
    }

    public function SetServer(string $host, int $port = 0): void {
        if ($host !== '' && $host[0] === '/') {
            $this->_path = 'unix://' . $host;
            return;
        }
        if (str_starts_with($host, "unix://")) {
            $this->_path = $host;
            return;
        }

        $this->_host = $host;
        $this->_port = $port;
        $this->_path = '';
    }

    public function SetConnectTimeout($timeout): void {
        $this->_timeout = (float)$timeout;
    }

    protected function _Send($handle, $data, $length): bool {
        if (feof($handle) || fwrite($handle, $data, $length) !== $length) {
            $this->_error = 'connection unexpectedly closed (timed out?)';
            $this->_connerror = true;
            return false;
        }
        return true;
    }

    protected function _MBPush(): void {
        $this->_mbenc = "";
        // mbstring.func_overload is removed in PHP 8.0, but we check for legacy
        if (function_exists('mb_internal_encoding') && (ini_get("mbstring.func_overload") & 2)) {
            $this->_mbenc = mb_internal_encoding();
            mb_internal_encoding("latin1");
        }
    }

    protected function _MBPop(): void {
        if ($this->_mbenc !== "" && function_exists('mb_internal_encoding')) {
            mb_internal_encoding($this->_mbenc);
        }
    }

    protected function _Connect() {
        if ($this->_socket !== false) {
            if (!feof($this->_socket)) return $this->_socket;
            $this->_socket = false;
        }

        $errno = 0;
        $errstr = "";
        $this->_connerror = false;

        $host = $this->_path ?: $this->_host;
        $port = $this->_path ? 0 : $this->_port;

        $fp = @fsockopen($host, $port, $errno, $errstr, $this->_timeout > 0 ? $this->_timeout : 5);

        if (!$fp) {
            $location = $this->_path ?: "{$this->_host}:{$this->_port}";
            $this->_error = "connection to $location failed (errno=$errno, msg=" . trim($errstr) . ")";
            $this->_connerror = true;
            return false;
        }

        if (!$this->_Send($fp, pack("N", 1), 4)) {
            fclose($fp);
            $this->_error = "failed to send client protocol version";
            return false;
        }

        $vData = fread($fp, 4);
        if (strlen($vData) < 4) {
             fclose($fp);
             $this->_error = "failed to read searchd version";
             return false;
        }
        $v = unpack("N", $vData)[1];
        if ($v < 1) {
            fclose($fp);
            $this->_error = "expected searchd protocol version 1+, got version '$v'";
            return false;
        }

        return $fp;
    }

    protected function _GetResponse($fp, $client_ver) {
        $response = "";
        $header = fread($fp, 8);
        if (strlen($header) == 8) {
            $data = unpack("nstatus/nver/Nlen", $header);
            $status = $data['status'];
            $ver = $data['ver'];
            $len = $data['len'];
            $left = $len;
            while ($left > 0 && !feof($fp)) {
                $chunk = fread($fp, min($left, 8192));
                if ($chunk) {
                    $response .= $chunk;
                    $left -= strlen($chunk);
                }
            }
        } else {
            $status = SEARCHD_ERROR; $ver = 0; $len = 0;
        }

        if ($this->_socket === false) fclose($fp);

        $read = strlen($response);
        if (!$response && $len > 0) {
            $this->_error = "received zero-sized searchd response";
            return false;
        }

        if ($status == SEARCHD_WARNING) {
            $wlen = unpack("N", substr($response, 0, 4))[1];
            $this->_warning = substr($response, 4, $wlen);
            return substr($response, 4 + $wlen);
        }
        if ($status == SEARCHD_ERROR || $status == SEARCHD_RETRY) {
            $this->_error = "searchd error: " . substr($response, 4);
            return false;
        }
        if ($status != SEARCHD_OK) {
            $this->_error = "unknown status code '$status'";
            return false;
        }

        if ($ver < $client_ver) {
            $this->_warning = sprintf("searchd command v.%d.%d older than client, some options might not work", $ver >> 8, $ver & 0xff);
        }

        return $response;
    }

    public function SetLimits(int $offset, int $limit, int $max = 0, int $cutoff = 0): void {
        $this->_offset = $offset;
        $this->_limit = $limit;
        if ($max > 0) $this->_maxmatches = $max;
        if ($cutoff > 0) $this->_cutoff = $cutoff;
    }

    public function SetMaxQueryTime(int $max): void {
        $this->_maxquerytime = max(0, $max);
    }

    public function SetMatchMode(int $mode): void {
        $this->_mode = $mode;
    }

    public function SetRankingMode(int $ranker): void {
        $this->_ranker = $ranker;
    }

    public function SetSortMode(int $mode, string $sortby = ""): void {
        $this->_sort = $mode;
        $this->_sortby = $sortby;
    }

    public function SetFieldWeights(array $weights): void {
        $this->_fieldweights = $weights;
    }

    public function SetIndexWeights(array $weights): void {
        $this->_indexweights = $weights;
    }

    public function SetIDRange($min, $max): void {
        $this->_min_id = $min;
        $this->_max_id = $max;
    }

    public function SetFilter(string $attribute, array $values, bool $exclude = false): void {
        if (!empty($values)) {
            $this->_filters[] = ["type" => SPH_FILTER_VALUES, "attr" => $attribute, "exclude" => $exclude, "values" => $values];
        }
    }

    public function SetFilterRange(string $attribute, $min, $max, bool $exclude = false): void {
        $this->_filters[] = ["type" => SPH_FILTER_RANGE, "attr" => $attribute, "exclude" => $exclude, "min" => $min, "max" => $max];
    }

    public function SetFilterFloatRange(string $attribute, float $min, float $max, bool $exclude = false): void {
        $this->_filters[] = ["type" => SPH_FILTER_FLOATRANGE, "attr" => $attribute, "exclude" => $exclude, "min" => $min, "max" => $max];
    }

    public function SetGeoAnchor(string $attrlat, string $attrlong, float $lat, float $long): void {
        $this->_anchor = ["attrlat" => $attrlat, "attrlong" => $attrlong, "lat" => $lat, "long" => $long];
    }

    public function SetGroupBy(string $attribute, int $func, string $groupsort = "@group desc"): void {
        $this->_groupby = $attribute;
        $this->_groupfunc = $func;
        $this->_groupsort = $groupsort;
    }

    public function SetGroupDistinct(string $attribute): void {
        $this->_groupdistinct = $attribute;
    }

    public function SetRetries(int $count, int $delay = 0): void {
        $this->_retrycount = $count;
        $this->_retrydelay = $delay;
    }

    public function SetArrayResult(bool $arrayresult): void {
        $this->_arrayresult = $arrayresult;
    }

    public function SetOverride(string $attrname, int $attrtype, array $values): void {
        $this->_overrides[$attrname] = ["attr" => $attrname, "type" => $attrtype, "values" => $values];
    }

    public function SetSelect(string $select): void {
        $this->_select = $select;
    }

    public function ResetFilters(): void {
        $this->_filters = [];
        $this->_anchor = [];
    }

    public function ResetGroupBy(): void {
        $this->_groupby = "";
        $this->_groupfunc = SPH_GROUPBY_DAY;
        $this->_groupsort = "@group desc";
        $this->_groupdistinct = "";
    }

    public function ResetOverrides(): void {
        $this->_overrides = [];
    }

    public function Query(string $query, string $index = "*", string $comment = "") {
        $this->_reqs = [];
        $this->AddQuery($query, $index, $comment);
        $results = $this->RunQueries();
        $this->_reqs = [];

        if (!is_array($results) || !isset($results[0])) return false;

        $this->_error = $results[0]["error"];
        $this->_warning = $results[0]["warning"];
        return ($results[0]["status"] == SEARCHD_ERROR) ? false : $results[0];
    }

    protected function _PackFloat($f): string {
        return pack("N", unpack("L", pack("f", (float)$f))[1]);
    }

    public function AddQuery(string $query, string $index = "*", string $comment = ""): int {
        $this->_MBPush();

        $req = pack("NNNNN", $this->_offset, $this->_limit, $this->_mode, $this->_ranker, $this->_sort);
        $req .= pack("N", strlen($this->_sortby)) . $this->_sortby;
        $req .= pack("N", strlen($query)) . $query;
        $req .= pack("N", count($this->_weights));
        foreach ($this->_weights as $weight) $req .= pack("N", (int)$weight);
        $req .= pack("N", strlen($index)) . $index;
        $req .= pack("N", 1);
        $req .= sphPackU64($this->_min_id) . sphPackU64($this->_max_id);

        $req .= pack("N", count($this->_filters));
        foreach ($this->_filters as $filter) {
            $req .= pack("N", strlen($filter["attr"])) . $filter["attr"];
            $req .= pack("N", $filter["type"]);
            switch ($filter["type"]) {
                case SPH_FILTER_VALUES:
                    $req .= pack("N", count($filter["values"]));
                    foreach ($filter["values"] as $value) $req .= sphPackI64($value);
                    break;
                case SPH_FILTER_RANGE:
                    $req .= sphPackI64($filter["min"]) . sphPackI64($filter["max"]);
                    break;
                case SPH_FILTER_FLOATRANGE:
                    $req .= $this->_PackFloat($filter["min"]) . $this->_PackFloat($filter["max"]);
                    break;
            }
            $req .= pack("N", (int)$filter["exclude"]);
        }

        $req .= pack("NN", $this->_groupfunc, strlen($this->_groupby)) . $this->_groupby;
        $req .= pack("N", $this->_maxmatches);
        $req .= pack("N", strlen($this->_groupsort)) . $this->_groupsort;
        $req .= pack("NNN", $this->_cutoff, $this->_retrycount, $this->_retrydelay);
        $req .= pack("N", strlen($this->_groupdistinct)) . $this->_groupdistinct;

        if (empty($this->_anchor)) {
            $req .= pack("N", 0);
        } else {
            $req .= pack("N", 1);
            $req .= pack("N", strlen($this->_anchor["attrlat"])) . $this->_anchor["attrlat"];
            $req .= pack("N", strlen($this->_anchor["attrlong"])) . $this->_anchor["attrlong"];
            $req .= $this->_PackFloat($this->_anchor["lat"]) . $this->_PackFloat($this->_anchor["long"]);
        }

        $req .= pack("N", count($this->_indexweights));
        foreach ($this->_indexweights as $idx => $weight) $req .= pack("N", strlen($idx)) . $idx . pack("N", (int)$weight);

        $req .= pack("N", $this->_maxquerytime);

        $req .= pack("N", count($this->_fieldweights));
        foreach ($this->_fieldweights as $field => $weight) $req .= pack("N", strlen($field)) . $field . pack("N", (int)$weight);

        $req .= pack("N", strlen($comment)) . $comment;

        $req .= pack("N", count($this->_overrides));
        foreach ($this->_overrides as $entry) {
            $req .= pack("N", strlen($entry["attr"])) . $entry["attr"];
            $req .= pack("NN", $entry["type"], count($entry["values"]));
            foreach ($entry["values"] as $id => $val) {
                $req .= sphPackU64($id);
                switch ($entry["type"]) {
                    case SPH_ATTR_FLOAT:  $req .= $this->_PackFloat($val); break;
                    case SPH_ATTR_BIGINT: $req .= sphPackI64($val); break;
                    default:              $req .= pack("N", (int)$val); break;
                }
            }
        }

        $req .= pack("N", strlen($this->_select)) . $this->_select;

        $this->_MBPop();
        $this->_reqs[] = $req;
        return count($this->_reqs) - 1;
    }

    public function RunQueries() {
        if (empty($this->_reqs)) {
            $this->_error = "no queries defined, issue AddQuery() first";
            return false;
        }

        $this->_MBPush();
        if (!($fp = $this->_Connect())) {
            $this->_MBPop();
            return false;
        }

        $nreqs = count($this->_reqs);
        $req = implode("", $this->_reqs);
        $len = 4 + strlen($req);
        $req = pack("nnNN", SEARCHD_COMMAND_SEARCH, VER_COMMAND_SEARCH, $len, $nreqs) . $req;

        if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_SEARCH))) {
            $this->_MBPop();
            return false;
        }

        $this->_reqs = [];
        $res = $this->_ParseSearchResponse($response, $nreqs);
        $this->_MBPop();
        return $res;
    }

    protected function _ParseSearchResponse($response, $nreqs): array {
        $p = 0;
        $max = strlen($response);
        $results = [];

        for ($ires = 0; $ires < $nreqs && $p < $max; $ires++) {
            $result = ["error" => "", "warning" => ""];
            $status = unpack("N", substr($response, $p, 4))[1]; $p += 4;
            $result["status"] = $status;
            
            if ($status != SEARCHD_OK) {
                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                $message = substr($response, $p, $len); $p += $len;
                if ($status == SEARCHD_WARNING) $result["warning"] = $message;
                else { $result["error"] = $message; $results[] = $result; continue; }
            }

            $fields = [];
            $nfields = unpack("N", substr($response, $p, 4))[1]; $p += 4;
            while ($nfields-- > 0 && $p < $max) {
                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                $fields[] = substr($response, $p, $len); $p += $len;
            }
            $result["fields"] = $fields;

            $attrs = [];
            $nattrs = unpack("N", substr($response, $p, 4))[1]; $p += 4;
            while ($nattrs-- > 0 && $p < $max) {
                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                $attr = substr($response, $p, $len); $p += $len;
                $type = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                $attrs[$attr] = $type;
            }
            $result["attrs"] = $attrs;

            $count = unpack("N", substr($response, $p, 4))[1]; $p += 4;
            $id64 = unpack("N", substr($response, $p, 4))[1]; $p += 4;

            $result["matches"] = [];
            while ($count-- > 0 && $p < $max) {
                if ($id64) {
                    $doc = sphUnpackU64(substr($response, $p, 8)); $p += 8;
                    $weight = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                } else {
                    $matchData = unpack("Ndoc/Nweight", substr($response, $p, 8)); $p += 8;
                    $doc = sphFixUint($matchData['doc']);
                    $weight = $matchData['weight'];
                }

                $attrvals = [];
                foreach ($attrs as $attr => $type) {
                    if ($type == SPH_ATTR_BIGINT) {
                        $attrvals[$attr] = sphUnpackI64(substr($response, $p, 8)); $p += 8;
                    } elseif ($type == SPH_ATTR_FLOAT) {
                        $uval = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                        $attrvals[$attr] = unpack("f", pack("L", $uval))[1];
                    } else {
                        $val = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                        if ($type & SPH_ATTR_MULTI) {
                            $vals = [];
                            $nvals = $val;
                            while ($nvals-- > 0 && $p < $max) {
                                $vals[] = sphFixUint(unpack("N", substr($response, $p, 4))[1]);
                                $p += 4;
                            }
                            $attrvals[$attr] = $vals;
                        } else {
                            $attrvals[$attr] = sphFixUint($val);
                        }
                    }
                }

                if ($this->_arrayresult) $result["matches"][] = ["id" => $doc, "weight" => $weight, "attrs" => $attrvals];
                else $result["matches"][$doc] = ["weight" => $weight, "attrs" => $attrvals];
            }

            $stats = unpack("Ntotal/Nfound/Nmsecs/Nwords", substr($response, $p, 16)); $p += 16;
            $result["total"] = sprintf("%u", $stats['total']);
            $result["total_found"] = sprintf("%u", $stats['found']);
            $result["time"] = sprintf("%.3f", $stats['msecs'] / 1000);

            $nwords = $stats['words'];
            while ($nwords-- > 0 && $p < $max) {
                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                $word = substr($response, $p, $len); $p += $len;
                $wstats = unpack("Ndocs/Nhits", substr($response, $p, 8)); $p += 8;
                $result["words"][$word] = ["docs" => sprintf("%u", $wstats['docs']), "hits" => sprintf("%u", $wstats['hits'])];
            }
            $results[] = $result;
        }
        return $results;
    }

    public function BuildExcerpts(array $docs, string $index, string $words, array $opts = []) {
        $this->_MBPush();
        if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; }

        $opts += [
            "before_match" => "<b>", "after_match" => "</b>", "chunk_separator" => " ... ",
            "limit" => 256, "around" => 5, "exact_phrase" => false, "single_passage" => false,
            "use_boundaries" => false, "weight_order" => false
        ];

        $flags = 1;
        if ($opts["exact_phrase"])   $flags |= 2;
        if ($opts["single_passage"]) $flags |= 4;
        if ($opts["use_boundaries"]) $flags |= 8;
        if ($opts["weight_order"])   $flags |= 16;

        $req = pack("NN", 0, $flags);
        $req .= pack("N", strlen($index)) . $index;
        $req .= pack("N", strlen($words)) . $words;
        $req .= pack("N", strlen($opts["before_match"])) . $opts["before_match"];
        $req .= pack("N", strlen($opts["after_match"])) . $opts["after_match"];
        $req .= pack("N", strlen($opts["chunk_separator"])) . $opts["chunk_separator"];
        $req .= pack("N", (int)$opts["limit"]);
        $req .= pack("N", (int)$opts["around"]);
        $req .= pack("N", count($docs));
        foreach ($docs as $doc) $req .= pack("N", strlen($doc)) . $doc;

        $len = strlen($req);
        $req = pack("nnN", SEARCHD_COMMAND_EXCERPT, VER_COMMAND_EXCERPT, $len) . $req;
        if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_EXCERPT))) {
            $this->_MBPop(); return false;
        }

        $pos = 0; $res = [];
        foreach ($docs as $unused) {
            $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4;
            $res[] = substr($response, $pos, $len); $pos += $len;
        }
        $this->_MBPop();
        return $res;
    }

    public function BuildKeywords(string $query, string $index, bool $hits) {
        $this->_MBPush();
        if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; }

        $req = pack("N", strlen($query)) . $query;
        $req .= pack("N", strlen($index)) . $index;
        $req .= pack("N", (int)$hits);

        $len = strlen($req);
        $req = pack("nnN", SEARCHD_COMMAND_KEYWORDS, VER_COMMAND_KEYWORDS, $len) . $req;
        if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_KEYWORDS))) {
            $this->_MBPop(); return false;
        }

        $pos = 0; $res = [];
        $nwords = unpack("N", substr($response, $pos, 4))[1]; $pos += 4;
        for ($i = 0; $i < $nwords; $i++) {
            $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4;
            $tokenized = substr($response, $pos, $len); $pos += $len;
            $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4;
            $normalized = substr($response, $pos, $len); $pos += $len;
            $entry = ["tokenized" => $tokenized, "normalized" => $normalized];
            if ($hits) {
                $wstats = unpack("Ndocs/Nhits", substr($response, $pos, 8)); $pos += 8;
                $entry["docs"] = $wstats['docs']; $entry["hits"] = $wstats['hits'];
            }
            $res[] = $entry;
        }
        $this->_MBPop();
        return $res;
    }

    public function EscapeString(string $string): string {
        $chars = ['\\', '(', ')', '|', '-', '!', '@', '~', '"', '&', '/', '^', '$', '='];
        $replacements = array_map(fn($c) => '\\' . $c, $chars);
        return str_replace($chars, $replacements, $string);
    }

    public function UpdateAttributes(string $index, array $attrs, array $values, bool $mva = false): int {
        $req = pack("N", strlen($index)) . $index;
        $req .= pack("N", count($attrs));
        foreach ($attrs as $attr) {
            $req .= pack("N", strlen($attr)) . $attr;
            $req .= pack("N", (int)$mva);
        }
        $req .= pack("N", count($values));
        foreach ($values as $id => $entry) {
            $req .= sphPackU64($id);
            foreach ($entry as $v) {
                $req .= pack("N", $mva ? count($v) : (int)$v);
                if ($mva) foreach ($v as $vv) $req .= pack("N", (int)$vv);
            }
        }

        if (!($fp = $this->_Connect())) return -1;
        $len = strlen($req);
        $req = pack("nnN", SEARCHD_COMMAND_UPDATE, VER_COMMAND_UPDATE, $len) . $req;
        if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_UPDATE))) return -1;

        return unpack("N", substr($response, 0, 4))[1];
    }

    public function Status() {
        $this->_MBPush();
        if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; }
        $req = pack("nnNN", SEARCHD_COMMAND_STATUS, VER_COMMAND_STATUS, 4, 1);
        if (!$this->_Send($fp, $req, 12) || !($response = $this->_GetResponse($fp, VER_COMMAND_STATUS))) {
            $this->_MBPop(); return false;
        }

        $p = 4;
        $stats = unpack("Nrows/Ncols", substr($response, $p, 8)); $p += 8;
        $rows = $stats['rows']; $cols = $stats['cols'];
        $res = [];
        for ($i = 0; $i < $rows; $i++) {
            for ($j = 0; $j < $cols; $j++) {
                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
                $res[$i][] = substr($response, $p, $len); $p += $len;
            }
        }
        $this->_MBPop();
        return $res;
    }
}
