<?php
// admin/routeros_api.php
// RouterOS API client (supports variable-length encoding + parsed responses)
// الهدف: منع Warnings من fsockopen + توفير command() المستخدمة في صفحات المشروع.

class RouterOSAPI
{
    private $socket;
    public bool $connected = false;

    private string $host;
    private string $user;
    private string $pass;
    private int $port;
    private float $connect_timeout = 1.2;
    private int $read_timeout_sec = 1;
    private int $read_timeout_usec = 0;
    private bool $read_timed_out = false;
    private float $command_deadline_sec = 4.0;
    private ?float $active_command_started_at = null;

    private function refreshExecutionBudget(): void
    {
        if (PHP_SAPI === 'cli') { return; }
        if (function_exists('set_time_limit')) { @set_time_limit(10); }
    }

    // Last connection error (usable by UI/API)
    public string $last_error = '';
    public int $last_errno = 0;

    public function __construct(string $host, string $user, string $pass, int $port = 8728)
    {
        $this->host = $host;
        $this->user = $user;
        $this->pass = $pass;
        $this->port = $this->normalizePort($port);
        $this->connect();
    }

    private function normalizePort(int $port): int
    {
        $p = (int)$port;
        return ($p >= 1 && $p <= 65535) ? $p : 8728;
    }

    public function connect(): bool
    {
        $this->last_error = '';
        $this->last_errno = 0;
        $this->connected = false;
        $this->read_timed_out = false;

        $errno = 0; $errstr = '';
        $target = 'tcp://' . $this->host . ':' . $this->port;
        $this->socket = @stream_socket_client($target, $errno, $errstr, $this->connect_timeout, STREAM_CLIENT_CONNECT);
        if (!$this->socket) {
            $this->last_errno = (int)$errno;
            $this->last_error = $errstr ? (string)$errstr : 'تعذر الاتصال بالراوتر';
            return false;
        }

        stream_set_timeout($this->socket, $this->read_timeout_sec, $this->read_timeout_usec);
        stream_set_blocking($this->socket, true);

        // Login
        $reply = $this->command(['/login', '=name='.$this->user, '=password='.$this->pass]);
        foreach ($reply as $row) {
            if (($row['.type'] ?? '') === '!done') {
                $this->connected = true;
                return true;
            }
            if (($row['.type'] ?? '') === '!trap') {
                $this->last_error = (string)($row['message'] ?? 'فشل تسجيل الدخول');
            }
        }
        if (!$this->last_error) $this->last_error = 'فشل تسجيل الدخول';
        return false;
    }

    /**
     * Mikrotik API variable-length encoding
     */
    private function writeLen(int $len): void
    {
        if ($len < 0x80) {
            fwrite($this->socket, chr($len));
        } elseif ($len < 0x4000) {
            $len |= 0x8000;
            fwrite($this->socket, chr(($len >> 8) & 0xFF) . chr($len & 0xFF));
        } elseif ($len < 0x200000) {
            $len |= 0xC00000;
            fwrite($this->socket, chr(($len >> 16) & 0xFF) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF));
        } elseif ($len < 0x10000000) {
            $len |= 0xE0000000;
            fwrite($this->socket, chr(($len >> 24) & 0xFF) . chr(($len >> 16) & 0xFF) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF));
        } else {
            fwrite($this->socket, chr(0xF0) . chr(($len >> 24) & 0xFF) . chr(($len >> 16) & 0xFF) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF));
        }
    }

    private function readLen(): int
    {
        $b1 = $this->readBytes(1);
        if ($b1 === null) return -1;
        $c = ord($b1);
        if (($c & 0x80) === 0x00) {
            return $c;
        }
        if (($c & 0xC0) === 0x80) {
            $c2 = $this->readBytes(1);
            if ($c2 === null) return -1;
            return (($c & ~0xC0) << 8) + ord($c2);
        }
        if (($c & 0xE0) === 0xC0) {
            $rest = $this->readBytes(2);
            if ($rest === null) return -1;
            return (($c & ~0xE0) << 16) + (ord($rest[0]) << 8) + ord($rest[1]);
        }
        if (($c & 0xF0) === 0xE0) {
            $rest = $this->readBytes(3);
            if ($rest === null) return -1;
            return (($c & ~0xF0) << 24) + (ord($rest[0]) << 16) + (ord($rest[1]) << 8) + ord($rest[2]);
        }
        $rest = $this->readBytes(4);
        if ($rest === null) return -1;
        return (ord($rest[0]) << 24) + (ord($rest[1]) << 16) + (ord($rest[2]) << 8) + ord($rest[3]);
    }

    private function writeWord(string $word): void
    {
        $this->writeLen(strlen($word));
        fwrite($this->socket, $word);
    }

    private function readBytes(int $length): ?string
    {
        if (!$this->socket || $length < 0) return null;
        if ($length === 0) return '';

        $buf = '';
        $startedAt = microtime(true);
        while (strlen($buf) < $length) {
            $this->refreshExecutionBudget();
            $now = microtime(true);
            $elapsed = $now - $startedAt;
            $commandElapsed = ($this->active_command_started_at !== null) ? ($now - $this->active_command_started_at) : 0.0;
            if ($elapsed >= ($this->read_timeout_sec + 0.35) || $commandElapsed >= $this->command_deadline_sec) {
                $this->read_timed_out = true;
                $this->last_error = 'انتهت مهلة قراءة RouterOS API';
                return null;
            }

            $remainingSec = max(0.15, min(1.0, ($this->read_timeout_sec + 0.35) - $elapsed, $this->command_deadline_sec - $commandElapsed));
            $sec = (int)floor($remainingSec);
            $usec = (int)(($remainingSec - $sec) * 1000000);
            $read = [$this->socket];
            $write = null;
            $except = null;
            $ready = @stream_select($read, $write, $except, $sec, $usec);
            if ($ready === false) {
                $this->last_error = 'تعذر انتظار بيانات من RouterOS API';
                return null;
            }
            if ($ready === 0) {
                continue;
            }

            $chunk = @fread($this->socket, $length - strlen($buf));
            if ($chunk === false || $chunk === '') {
                $meta = is_resource($this->socket) ? stream_get_meta_data($this->socket) : [];
                if (!empty($meta['timed_out'])) {
                    $this->read_timed_out = true;
                    $this->last_error = 'انتهت مهلة قراءة RouterOS API';
                } elseif (!empty($meta['eof'])) {
                    $this->last_error = $this->last_error ?: 'انقطع اتصال RouterOS API';
                } else {
                    $this->last_error = $this->last_error ?: 'لم تصل بيانات من RouterOS API';
                }
                return null;
            }
            $buf .= $chunk;
        }
        return $buf;
    }

    private function readWord(): string
    {
        $len = $this->readLen();
        if ($len < 0) return '';
        if ($len === 0) return '';
        $data = $this->readBytes($len);
        return $data === null ? '' : (string)$data;
    }

    private function writeSentence(array $words): void
    {
        foreach ($words as $w) {
            $this->writeWord((string)$w);
        }
        $this->writeWord('');
    }

    private function readSentence(): array
    {
        $words = [];
        while (true) {
            $w = $this->readWord();
            if ($w === '') break;
            $words[] = $w;
        }
        return $words;
    }

    /**
     * Lightweight file cache for safe read-only RouterOS commands.
     * This reduces duplicated polling when several widgets/pages request the same data at once.
     */
    private function uaCacheEnabled(): bool
    {
        if (defined('UA_MT_COMMAND_CACHE_DISABLED') && UA_MT_COMMAND_CACHE_DISABLED) return false;
        return true;
    }

    private function uaCacheDir(): string
    {
        $dir = __DIR__ . '/cache/runtime/mt_command_cache';
        if (!is_dir($dir)) @mkdir($dir, 0777, true);
        return $dir;
    }

    private function uaRouterCachePrefix(): string
    {
        return substr(sha1($this->host . '|' . $this->port . '|' . $this->user), 0, 16);
    }

    private function uaNormalizeSentenceForCache(array $sentence): array
    {
        $out = [];
        foreach ($sentence as $w) {
            $w = (string)$w;
            // These are runtime-only flags if a caller ever needs to bypass cache.
            if ($w === '=__ua_cache_bypass=yes' || $w === '=__ua_cache_bypass=true') continue;
            $out[] = $w;
        }
        return $out;
    }

    private function uaSentenceWantsCacheBypass(array $sentence): bool
    {
        foreach ($sentence as $w) {
            $w = (string)$w;
            if ($w === '=__ua_cache_bypass=yes' || $w === '=__ua_cache_bypass=true') return true;
        }
        return false;
    }

    private function uaReadCacheTtl(array $sentence): int
    {
        if (!$this->uaCacheEnabled() || $this->uaSentenceWantsCacheBypass($sentence)) return 0;
        $path = (string)($sentence[0] ?? '');
        if ($path === '' || $path === '/login') return 0;

        // Cache only RouterOS read commands. Mutating commands are never cached.
        $allowed = [
            '/system/resource/print' => 3,
            '/system/identity/print' => 60,
            '/ppp/active/print' => 5,
            '/ppp/profile/print' => 45,
            '/ppp/secret/print' => 20,
            '/queue/simple/print' => 8,
            '/interface/monitor-traffic' => 2,
            '/interface/print' => 8,
            '/interface/pppoe-client/print' => 20,
            '/interface/pppoe-server/server/print' => 20,
            '/interface/bridge/print' => 30,
            '/interface/bridge/port/print' => 30,
            '/interface/vlan/print' => 30,
            '/ip/address/print' => 20,
            '/ip/route/print' => 20,
            '/ip/dhcp-client/print' => 20,
            '/ip/pool/print' => 60,
            '/ip/service/print' => 20,
            '/ip/hotspot/active/print' => 5,
            '/ip/hotspot/user/print' => 20,
            '/ip/hotspot/user/profile/print' => 45,
        ];
        if (isset($allowed[$path])) return (int)$allowed[$path];

        // Generic safe fallback for print-only paths not listed above.
        if (substr($path, -6) === '/print') return 8;
        if ($path === '/ping') return 2;
        return 0;
    }

    private function uaCacheFile(array $sentence): string
    {
        $normalized = $this->uaNormalizeSentenceForCache($sentence);
        $key = sha1(json_encode($normalized, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
        return $this->uaCacheDir() . '/' . $this->uaRouterCachePrefix() . '_' . $key . '.json';
    }

    private function uaCacheLockFile(array $sentence): string
    {
        return $this->uaCacheFile($sentence) . '.lock';
    }

    private function uaCacheLockAcquire(array $sentence, int $ttl)
    {
        if ($ttl <= 0) return false;
        $lockFile = $this->uaCacheLockFile($sentence);
        if (is_file($lockFile) && (time() - (int)@filemtime($lockFile)) > max(3, $ttl + 2)) @unlink($lockFile);
        $fh = @fopen($lockFile, 'c+');
        if (!$fh) return false;
        if (!@flock($fh, LOCK_EX | LOCK_NB)) { @fclose($fh); return false; }
        @ftruncate($fh, 0);
        @fwrite($fh, (string)getmypid());
        @touch($lockFile);
        return ['fh' => $fh, 'path' => $lockFile];
    }

    private function uaCacheLockRelease($fh): void
    {
        $path = '';
        if (is_array($fh)) {
            $path = (string)($fh['path'] ?? '');
            $fh = $fh['fh'] ?? null;
        }
        if ($fh) { @flock($fh, LOCK_UN); @fclose($fh); }
        if ($path !== '') { @unlink($path); }
    }

    private function uaReadCommandCache(array $sentence, int $ttl): ?array
    {
        if ($ttl <= 0) return null;
        $file = $this->uaCacheFile($sentence);
        if (!is_file($file)) return null;
        $age = time() - (int)@filemtime($file);
        if ($age < 0 || $age > $ttl) return null;
        $raw = @file_get_contents($file);
        if ($raw === false || $raw === '') return null;
        $payload = json_decode($raw, true);
        if (!is_array($payload) || !isset($payload['rows']) || !is_array($payload['rows'])) return null;
        return $payload['rows'];
    }

    private function uaWriteCommandCache(array $sentence, array $rows, int $ttl): void
    {
        if ($ttl <= 0) return;
        // Never cache traps/fatals as valid data.
        foreach ($rows as $r) {
            if (is_array($r) && in_array(($r['.type'] ?? ''), ['!trap','!fatal'], true)) return;
        }
        $payload = [
            'ts' => time(),
            'path' => (string)($sentence[0] ?? ''),
            'sentence' => $this->uaNormalizeSentenceForCache($sentence),
            'rows' => $rows,
        ];
        $file = $this->uaCacheFile($sentence);
        $tmp = $file . '.tmp';
        if (@file_put_contents($tmp, json_encode($payload, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), LOCK_EX) !== false) {
            @rename($tmp, $file);
        }
    }

    private function uaIsMutatingSentence(array $sentence): bool
    {
        $path = (string)($sentence[0] ?? '');
        if ($path === '' || $path === '/login') return false;
        if ($this->uaReadCacheTtl($sentence) > 0) return false;
        return true;
    }

    private function uaInvalidateRouterCache(): void
    {
        $dir = $this->uaCacheDir();
        $prefix = $this->uaRouterCachePrefix() . '_';
        foreach (glob($dir . '/' . $prefix . '*.json') ?: [] as $file) {
            @unlink($file);
        }
    }

    /**
     * Command compatible with existing pages:
     * $api->command(['/ppp/secret/print', '?name=user'])
     * Returns array of rows: each row includes '.type' and key/value pairs.
     */
    public function command(array $sentence): array
    {
        if (!$this->socket) return [['.type' => '!trap', 'message' => 'لا يوجد Socket']];

        $cacheTtl = $this->uaReadCacheTtl($sentence);
        $cacheLock = false;
        if ($cacheTtl > 0) {
            $cachedRows = $this->uaReadCommandCache($sentence, $cacheTtl);
            if (is_array($cachedRows)) return $cachedRows;
            $cacheLock = $this->uaCacheLockAcquire($sentence, $cacheTtl);
            if (!$cacheLock) {
                // A sibling request is already building the same router read. Give it a tiny chance to finish.
                usleep(120000);
                $cachedRows = $this->uaReadCommandCache($sentence, $cacheTtl);
                if (is_array($cachedRows)) return $cachedRows;
            } else {
                // Re-check after acquiring the lock.
                $cachedRows = $this->uaReadCommandCache($sentence, $cacheTtl);
                if (is_array($cachedRows)) { $this->uaCacheLockRelease($cacheLock); return $cachedRows; }
            }
        }

        $wireSentence = $this->uaNormalizeSentenceForCache($sentence);
        $this->refreshExecutionBudget();
        $this->read_timed_out = false;
        $this->active_command_started_at = microtime(true);
        $this->writeSentence($wireSentence);

        $out = [];
        while (true) {
            $words = $this->readSentence();
            if (!$words) {
                if ($this->read_timed_out) {
                    $out[] = ['.type' => '!trap', 'message' => ($this->last_error ?: 'انتهت مهلة قراءة RouterOS API')];
                    break;
                }
                $meta = is_resource($this->socket) ? stream_get_meta_data($this->socket) : [];
                if (!empty($meta['eof'])) {
                    $out[] = ['.type' => '!trap', 'message' => ($this->last_error ?: 'انقطع اتصال RouterOS API')];
                    break;
                }
                continue;
            }

            $type = $words[0] ?? '';
            $row = ['.type' => $type];
            for ($i = 1; $i < count($words); $i++) {
                $w = $words[$i];
                // format: =key=value OR .id=*1 OR message=...
                if (strpos($w, '=') === 0) {
                    $w2 = substr($w, 1);
                    $pos = strpos($w2, '=');
                    if ($pos !== false) {
                        $k = substr($w2, 0, $pos);
                        $v = substr($w2, $pos + 1);
                        $row[$k] = $v;
                    } else {
                        $row[$w2] = '';
                    }
                } elseif (strpos($w, '.') === 0) {
                    // .id=*1  (some clients send without leading '=' )
                    $pos = strpos($w, '=');
                    if ($pos !== false) {
                        $k = substr($w, 0, $pos);
                        $v = substr($w, $pos + 1);
                        $row[$k] = $v;
                    } else {
                        $row[$w] = '';
                    }
                } else {
                    $pos = strpos($w, '=');
                    if ($pos !== false) {
                        $k = substr($w, 0, $pos);
                        $v = substr($w, $pos + 1);
                        $row[$k] = $v;
                    } else {
                        $row[] = $w;
                    }
                }
            }

            $out[] = $row;

            if ($type === '!done') break;
            if ($type === '!trap') break;
            if ($type === '!fatal') break;
        }

        $this->active_command_started_at = null;
        if ($cacheTtl > 0) {
            $this->uaWriteCommandCache($sentence, $out, $cacheTtl);
            $this->uaCacheLockRelease($cacheLock);
        } elseif ($this->uaIsMutatingSentence($sentence)) {
            // Any write/update invalidates snapshots so the next read sees the changed router state.
            $this->uaInvalidateRouterCache();
        }
        return $out;
    }

    /**
     * Backward compatible helper.
     */
    public function comm(string $cmd, array $params = []): array
    {
        $sentence = [$cmd];
        foreach ($params as $k => $v) {
            if ($k === '.id') $sentence[] = '=.id=' . $v;
            else $sentence[] = '=' . $k . '=' . $v;
        }
        return $this->command($sentence);
    }

    public function disconnect(): void
    {
        if ($this->socket) {
            @fclose($this->socket);
        }
        $this->connected = false;
    }
}
