import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import { settings } from "./config.js";
import { fetchSubscription } from "./subscription.js";
import os from "node:os";
import {
  buildGatewayConfig,
  writeSingboxConfig,
  readSingboxConfig,
  removeSingboxConfig,
} from "./singbox.js";
import {
  legacyDeviceRulesFromProfiles,
  readDeviceProfiles,
  writeDeviceProfiles,
} from "./devices.js";
import {
  readClientSettings,
  writeClientSettings,
} from "./clientSettings.js";
import {
  buildSharedProxyInfo,
  checkSharedProxyGateway,
} from "./sharedProxy.js";
import {
  sourceBypassCidrs,
  syncTproxySourceBypass,
} from "./tproxySourceBypass.js";
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
import { tcpPing, resolveHost } from "./ping.js";

const APPLY_HISTORY_LIMIT = 10;
const RULE_SET_TAG_RE = /^[a-z0-9][a-z0-9_.@!-]*$/i;
const FALLBACK_RULE_SET_CATALOG = {
  geosite: [
    "geosite-category-ru",
    "geosite-category-ai-!cn",
    "geosite-geolocation-!cn",
    "geosite-google",
    "geosite-youtube",
    "geosite-telegram",
    "geosite-openai",
    "geosite-apple",
    "geosite-github",
    "geosite-steam",
    "geosite-discord",
    "geosite-netflix",
    "geosite-cloudflare",
    "geosite-category-ads-all",
  ],
  geoip: [
    "geoip-ru",
    "geoip-cloudflare",
    "geoip-telegram",
    "geoip-google",
    "geoip-netflix",
    "geoip-private",
  ],
};

fs.mkdirSync(settings.dataDir, { recursive: true });

const SINGBOX_PID_FILE = path.join(settings.dataDir, "singbox.pid");

// ─── Direct bypass cache (ipset) ────────────────────────────────────────────
const DIRECT_BYPASS_SET = process.env.DIRECT_BYPASS_SET || "vpn_direct_bypass";
const DIRECT_BYPASS_TTL = process.env.DIRECT_BYPASS_TTL || "3600";
const DIRECT_BYPASS_CACHE = process.env.DIRECT_BYPASS_CACHE === "true";
const IPSET_AVAILABLE = (() => {
  try {
    const result = spawnSync("ipset", ["version"], { timeout: 1000 });
    return !result.error && result.status === 0;
  } catch {
    return false;
  }
})();
const IP_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/;

// Локальный счётчик добавленных IP (RAM-only, сбрасывается при перезапуске)
let directBypassCount = 0;

function addToDirectBypass(ip) {
  if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE || !IP_RE.test(ip)) return;
  try {
    spawnSync(
      "ipset",
      ["add", DIRECT_BYPASS_SET, ip, "timeout", DIRECT_BYPASS_TTL, "-exist"],
      {
        timeout: 500,
      },
    );
    directBypassCount++;
  } catch {}
}

function flushDirectBypass() {
  directBypassCount = 0;
  if (!IPSET_AVAILABLE) return;
  try {
    spawnSync("ipset", ["flush", DIRECT_BYPASS_SET], { timeout: 1000 });
  } catch {}
}

function listDirectBypass() {
  if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE) return [];
  try {
    const result = spawnSync(
      "ipset",
      ["list", DIRECT_BYPASS_SET, "-output", "plain"],
      {
        encoding: "utf8",
        timeout: 2000,
      },
    );
    const lines = (result.stdout || "").split("\n");
    // После строки "Members:" идут IP-адреса
    const membersIdx = lines.findIndex((l) => l.trim() === "Members:");
    if (membersIdx === -1) return [];
    return lines
      .slice(membersIdx + 1)
      .map((l) => l.trim().split(" ")[0])
      .filter((l) => IP_RE.test(l));
  } catch {
    return [];
  }
}

let singboxProcess = null;
let singboxStartedAt = null;
const LOG_BUFFER_SIZE = 500;
const logBuffer = [];
const logSubscribers = new Set();

const TRAFFIC_BUFFER_SIZE = 500;
const trafficBuffer = [];
const trafficSubscribers = new Set();

// Паттерны для парсинга трафика из логов sing-box.
// Форматы логов sing-box:
//   [TCP] 192.168.1.1:PORT --> example.com:443 outbound/direct[direct]
//   [UDP] 192.168.1.1:PORT --> 8.8.8.8:53 outbound/direct[direct]
//   [router] match[N][rule-name] => outbound/direct[tag]
//   outbound/direct[tag]: dial tcp connection to host:port

// Назначение после --> (старый формат sing-box)
const DEST_ARROW_RE = /-->\s*([\w.\-]+):(\d{1,5})/;
// Назначение в словесном стиле
const DEST_WORD_RE =
  /(?:connection\s+to|dial(?:ing)?|connect(?:ing)?\s+to)\s+([\w.\-]+):(\d{1,5})/i;
// Тип аутбаунда: outbound/TYPE[tag] или outbound/TYPE
const OUTBOUND_RE = /outbound\/([a-z0-9_\-]+)/i;
// Строка роутера: [router] match[N][rule-name] => outbound/TYPE[tag]
const ROUTER_MATCH_LINE_RE =
  /\[router\].*\bmatch\[\d+\]\[([^\]]+)\].*outbound\/([a-z0-9_\-]+)/i;
// ID соединения: [CONN_ID Nms]
const CONN_ID_RE = /\[(\d{5,12})\s+\d+ms\]/;
// Входящее соединение от устройства: inbound [packet] connection from IP:PORT
const INBOUND_FROM_RE =
  /inbound(?:\s+packet)?\s+connection\s+from\s+([\d.]+):\d+/i;
// Source IP из --> формата: IP:PORT -->
const SOURCE_ARROW_RE = /\b([\d.]+):\d+\s+-->/;
// Карта source IP по ID соединения
const CONN_TTL_MS = 10_000;
const connSourceMap = new Map();
setInterval(() => {
  const now = Date.now();
  for (const [id, v] of connSourceMap) {
    if (now - v.at > CONN_TTL_MS) connSourceMap.delete(id);
  }
}, 30_000);

// Хранит имя последнего правила из [router] строки (для следующей строки с dest)
let _pendingRuleName = null;
let _pendingRuleAt = 0;
const RULE_CONTEXT_TTL_MS = 500;

function parseTrafficLine(line) {
  // Расширенная очистка ANSI (включая многопараметрические: \x1b[38;5;Nm)
  const clean = line.replace(/\x1b\[[0-9;]*m/g, "").trim();

  // Детектируем строку роутера — содержит правило, но не dest
  const routerM = clean.match(ROUTER_MATCH_LINE_RE);
  if (routerM) {
    _pendingRuleName = routerM[1];
    _pendingRuleAt = Date.now();
    return null;
  }

  // Извлекаем ID соединения для корреляции
  const connM = clean.match(CONN_ID_RE);
  const connId = connM ? connM[1] : null;

  // Строка "inbound connection from IP:PORT" — сохраняем source IP и выходим
  const inboundFromM = clean.match(INBOUND_FROM_RE);
  if (inboundFromM) {
    if (connId)
      connSourceMap.set(connId, { sourceIp: inboundFromM[1], at: Date.now() });
    return null;
  }

  // Берём накопленное имя правила, если свежее
  let inheritedRule = null;
  if (_pendingRuleName && Date.now() - _pendingRuleAt < RULE_CONTEXT_TTL_MS) {
    inheritedRule = _pendingRuleName;
  }
  _pendingRuleName = null;
  _pendingRuleAt = 0;

  // Ищем outbound
  const obM = clean.match(OUTBOUND_RE);
  if (!obM) return null;
  const outboundRaw = obM[1].toLowerCase();

  // Пропускаем DNS-аутбаунды
  if (outboundRaw === "dns-out" || outboundRaw === "dns") return null;

  let category;
  if (outboundRaw === "direct" || outboundRaw.startsWith("direct-"))
    category = "direct";
  else if (outboundRaw === "block" || outboundRaw === "reject")
    category = "block";
  else category = "vpn";

  // Ищем назначение: --> (старый формат), потом словесный (inbound/outbound connection to)
  const destM = clean.match(DEST_ARROW_RE) || clean.match(DEST_WORD_RE);
  if (!destM) return null;

  const host = destM[1];
  const port = parseInt(destM[2], 10);

  // Source IP: из корреляционной карты (новый формат) или из --> (старый формат)
  let sourceIp = null;
  if (connId) {
    const stored = connSourceMap.get(connId);
    if (stored && Date.now() - stored.at < CONN_TTL_MS)
      sourceIp = stored.sourceIp;
  }
  if (!sourceIp) {
    const srcM = clean.match(SOURCE_ARROW_RE);
    if (srcM) sourceIp = srcM[1];
  }

  return {
    ts: new Date().toISOString(),
    outbound: outboundRaw,
    category,
    host,
    port,
    sourceIp,
    matchedRule: inheritedRule,
  };
}

function pushTrafficEntry(entry) {
  trafficBuffer.push(entry);
  if (trafficBuffer.length > TRAFFIC_BUFFER_SIZE) trafficBuffer.shift();
  for (const sub of trafficSubscribers) {
    try {
      sub(entry);
    } catch {}
  }
}

function pushLog(level, line) {
  const entry = { ts: new Date().toISOString(), level, line };
  logBuffer.push(entry);
  if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
  for (const subscriber of logSubscribers) {
    try {
      subscriber(entry);
    } catch {}
  }
  // Парсим трафик из info/debug строк
  if (level === "info" || level === "debug") {
    const traffic = parseTrafficLine(line);
    if (traffic) {
      pushTrafficEntry(traffic);
      // Если direct и назначение — IP, добавляем в bypass-кэш
      if (traffic.category === "direct" && IP_RE.test(traffic.host)) {
        addToDirectBypass(traffic.host);
      }
    } else if (
      (level === "info" || level === "debug") &&
      _debugUnparsed < 30 &&
      /\bTCP\b|\bUDP\b|\boutbound\b/i.test(line.replace(/\x1b\[\d+m/g, ""))
    ) {
      _debugUnparsed++;
      process.stdout.write(
        `[traffic:unmatched] ${line.replace(/\x1b\[\d+m/g, "").trim()}\n`,
      );
    }
  }
}

let _debugUnparsed = 0;

// Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки.
// Формат: ESC[<n>m LEVEL ESC[0m, где ESC = \x1b
const SINGBOX_LEVEL_RE =
  /\x1b\[\d+m(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\x1b\[0m/i;
function parseSingboxLevel(line, fallback) {
  const m = line.match(SINGBOX_LEVEL_RE);
  if (!m) return fallback;
  const l = m[1].toLowerCase();
  if (l === "warn") return "warning";
  if (l === "fatal") return "error";
  return l; // trace, debug, info, error
}

// ─── PID helpers ────────────────────────────────────────────────────────────

function saveSingboxPid(pid) {
  try {
    fs.writeFileSync(SINGBOX_PID_FILE, String(pid), "utf8");
  } catch {}
}

function readSingboxPid() {
  try {
    const pid = parseInt(fs.readFileSync(SINGBOX_PID_FILE, "utf8").trim(), 10);
    return Number.isFinite(pid) && pid > 0 ? pid : null;
  } catch {
    return null;
  }
}

function removeSingboxPid() {
  try {
    fs.unlinkSync(SINGBOX_PID_FILE);
  } catch {}
}

function isPidAlive(pid) {
  if (!pid) return false;
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

/**
 * Подхватывает уже запущенный sing-box по PID — без перезапуска.
 * Логи недоступны (процесс запущен раньше), но kill/stop работает.
 */
function attachExistingSingbox(pid) {
  const stateData = readJson(settings.statePath, {});
  singboxStartedAt = stateData.appliedAt || new Date().toISOString();

  let exitCb = null;
  singboxProcess = {
    pid,
    kill: (sig = "SIGTERM") => {
      try {
        process.kill(pid, sig);
      } catch {}
    },
    once: (event, cb) => {
      if (event === "exit") exitCb = cb;
    },
  };

  // Периодически проверяем, что процесс ещё жив
  const watcher = setInterval(() => {
    if (!isPidAlive(pid)) {
      clearInterval(watcher);
      if (singboxProcess?.pid === pid) {
        singboxProcess = null;
        singboxStartedAt = null;
        removeSingboxPid();
        pushLog("warning", `sing-box (pid=${pid}) завершился`);
      }
      if (exitCb) {
        exitCb(null, null);
        exitCb = null;
      }
    }
  }, 2000);

  pushLog("info", `sing-box подхвачен при старте (pid=${pid})`);
}

function captureStream(stream, fallbackLevel) {
  let remainder = "";
  stream.setEncoding("utf8");
  stream.on("data", (chunk) => {
    const data = remainder + chunk;
    const lines = data.split(/\r?\n/);
    remainder = lines.pop() || "";
    for (const line of lines) {
      if (!line) continue;
      const level = parseSingboxLevel(line, fallbackLevel);
      process.stdout.write(`[sing-box:${level}] ${line}\n`);
      pushLog(level, line);
    }
  });
  stream.on("end", () => {
    if (remainder) {
      const level = parseSingboxLevel(remainder, fallbackLevel);
      process.stdout.write(`[sing-box:${level}] ${remainder}\n`);
      pushLog(level, remainder);
    }
    remainder = "";
  });
}

function readJson(filePath, fallback) {
  try {
    if (!fs.existsSync(filePath)) return fallback;
    return JSON.parse(fs.readFileSync(filePath, "utf8"));
  } catch {
    return fallback;
  }
}

function writeJson(filePath, value) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
}

function maskSubscriptionUrl(url) {
  if (!url) return "";
  try {
    const parsed = new URL(url);
    return `${parsed.hostname}/...`;
  } catch {
    return url.length > 32 ? `${url.slice(0, 32)}...` : url;
  }
}

function sendJson(res, statusCode, payload) {
  const body = JSON.stringify(payload, null, 2);
  res.writeHead(statusCode, {
    "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(body),
  });
  res.end(body);
}

function normalizeRuleSetUrl(url) {
  const value = String(url || "").trim();
  if (!value) return [];

  const urls = [value];

  const jsdelivrMatch = value.match(
    /^https:\/\/cdn\.jsdelivr\.net\/gh\/([^/]+)\/([^@/]+)@([^/]+)\/(.+)$/i,
  );
  if (jsdelivrMatch) {
    const [, owner, repo, ref, filePath] = jsdelivrMatch;
    urls.push(
      `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`,
    );
  }

  const rawMatch = value.match(
    /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/i,
  );
  if (rawMatch) {
    const [, owner, repo, ref, filePath] = rawMatch;
    urls.push(`https://cdn.jsdelivr.net/gh/${owner}/${repo}@${ref}/${filePath}`);
  }

  return Array.from(new Set(urls));
}

function downloadFile(urlOrUrls, outputPath) {
  return new Promise((resolve, reject) => {
    const candidates = Array.isArray(urlOrUrls)
      ? Array.from(new Set(urlOrUrls.flatMap((url) => normalizeRuleSetUrl(url))))
      : normalizeRuleSetUrl(urlOrUrls);
    let index = 0;
    let lastError = "";

    function tryNext() {
      const url = candidates[index];
      if (!url) {
        reject(new Error(lastError || "Не удалось скачать файл"));
        return;
      }

      let stderr = "";
      const dl = spawn("curl", [
        "-fsSL",
        "--retry",
        "2",
        "--retry-delay",
        "1",
        "--connect-timeout",
        "10",
        "--max-time",
        "45",
        "-A",
        "vpn-proxy-app",
        url,
        "-o",
        outputPath,
      ]);
      dl.stderr.on("data", (d) => {
        stderr += d;
      });
      dl.on("error", (err) => {
        lastError = err.message;
        index += 1;
        tryNext();
      });
      dl.on("close", (code) => {
        if (code === 0) {
          resolve(url);
          return;
        }
        lastError = `curl ${url} завершился с кодом ${code}: ${stderr}`;
        index += 1;
        tryNext();
      });
    }

    tryNext();
  });
}

function readBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      if (!chunks.length) return resolve({});
      try {
        resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
      } catch {
        reject(new Error("Невалидный JSON в теле запроса"));
      }
    });
    req.on("error", reject);
  });
}

function checkSingboxConfig() {
  const result = spawnSync("sing-box", ["check", "-c", settings.configPath], {
    encoding: "utf8",
  });

  if (result.status !== 0) {
    throw new Error(
      (result.stderr || result.stdout || "sing-box check failed").trim(),
    );
  }
}

function stopSingbox() {
  return new Promise((resolve) => {
    if (!singboxProcess) {
      singboxStartedAt = null;
      return resolve();
    }

    const current = singboxProcess;
    singboxProcess = null;
    singboxStartedAt = null;

    const timeout = setTimeout(() => {
      current.kill("SIGKILL");
      resolve();
    }, 4000);

    current.once("exit", () => {
      clearTimeout(timeout);
      resolve();
    });

    current.kill("SIGTERM");
  });
}

async function startSingbox() {
  if (!fs.existsSync(settings.configPath)) return false;

  checkSingboxConfig();
  await stopSingbox();

  singboxProcess = spawn("sing-box", ["run", "-c", settings.configPath], {
    stdio: ["ignore", "pipe", "pipe"],
  });
  singboxStartedAt = new Date().toISOString();
  saveSingboxPid(singboxProcess.pid);
  pushLog("info", `sing-box запущен (pid=${singboxProcess.pid})`);

  captureStream(singboxProcess.stdout, "info");
  captureStream(singboxProcess.stderr, "error");

  singboxProcess.once("exit", (code, signal) => {
    pushLog("info", `sing-box завершён: code=${code} signal=${signal}`);
    singboxProcess = null;
    singboxStartedAt = null;
    removeSingboxPid();
  });

  return true;
}

function publicState() {
  const state = readJson(settings.statePath, {});
  const customRules = readJson(settings.customRulesPath, []);
  const deviceProfiles = readDeviceProfiles();
  const clientSettings = readClientSettings();
  const { subscriptionUrl, ...rest } = state;
  return {
    mode: settings.appMode,
    port: settings.port,
    proxyPort:
      settings.appMode === "client" ? clientSettings.proxyPort : settings.proxyPort,
    clientProxyPortRange: {
      start: settings.clientProxyPortStart,
      end: settings.clientProxyPortEnd,
    },
    proxyBindIp: settings.bindIp,
    tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
    routingRuDirect: settings.routingRuDirect,
    clientSettings,
    configExists: fs.existsSync(settings.configPath),
    singboxRunning: Boolean(singboxProcess),
    singboxStartedAt,
    subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
    hasSubscription: Boolean(subscriptionUrl),
    customRules,
    devicesConfig: deviceProfiles,
    devices: deviceProfiles.devices,
    deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles),
    appliedHistory: state.appliedHistory || [],
    rulesUpdatedAt: state.rulesUpdatedAt || null,
    devicesUpdatedAt: state.devicesUpdatedAt || null,
    rulesAppliedAt: state.rulesAppliedAt || null,
    bypassMode: Boolean(state.bypassMode),
    directBypassCount,
    directBypassEnabled: DIRECT_BYPASS_CACHE,
    directBypassAvailable: IPSET_AVAILABLE,
    sourceBypassCidrs: sourceBypassCidrs(deviceProfiles),
    ...rest,
  };
}

function normalizeList(value) {
  if (Array.isArray(value)) {
    return value.map((item) => String(item || "").trim()).filter(Boolean);
  }
  return String(value || "")
    .split(/\r?\n|,/)
    .map((item) => item.trim())
    .filter(Boolean);
}

function normalizeCustomRules(input) {
  const rules = Array.isArray(input) ? input : [];
  return rules.map((rule, index) => ({
    id: String(rule.id || `rule-${Date.now()}-${index}`),
    name: String(rule.name || `Правило ${index + 1}`).trim(),
    enabled: rule.enabled !== false,
    outbound: ["direct", "vpn", "block"].includes(rule.outbound)
      ? rule.outbound
      : "direct",
    domains: normalizeList(rule.domains),
    domainSuffixes: normalizeList(rule.domainSuffixes),
    domainKeywords: normalizeList(rule.domainKeywords),
    ipCidrs: normalizeList(rule.ipCidrs),
    ports: normalizeList(rule.ports),
    networks: normalizeList(rule.networks).filter((network) =>
      ["tcp", "udp"].includes(network),
    ),
    ruleSets: normalizeList(rule.ruleSets).filter((tag) =>
      RULE_SET_TAG_RE.test(tag),
    ),
  }));
}

function normalizeDeviceRules(input) {
  const rules = Array.isArray(input) ? input : [];
  return rules.map((r, index) => ({
    id: String(r.id || `dev-${Date.now()}-${index}`),
    name: String(r.name || `Устройство ${index + 1}`).trim(),
    enabled: r.enabled !== false,
    sourceIps: normalizeList(r.sourceIps).filter((ip) =>
      /^[\.\d:/]+$/.test(ip),
    ),
    outbound: ["direct", "vpn", "block"].includes(r.outbound)
      ? r.outbound
      : "direct",
  }));
}

async function applySelectedServer(selectedTag) {
  const cached = readJson(settings.subscriptionCachePath, null);
  if (!cached?.config) {
    throw new Error("Сначала загрузите подписку");
  }

  const customRules = readJson(settings.customRulesPath, []);
  const stateForBypass = readJson(settings.statePath, {});
  const generated = buildGatewayConfig(
    { ...cached.config, customRules },
    selectedTag,
    { bypassAll: Boolean(stateForBypass.bypassMode) },
  );
  writeSingboxConfig(generated);
  await startSingbox();

  const prevState = readJson(settings.statePath, {});
  const now = new Date().toISOString();
  const previousTag =
    prevState.selectedTag && prevState.selectedTag !== selectedTag
      ? prevState.selectedTag
      : prevState.previousTag || null;
  const history = Array.isArray(prevState.appliedHistory)
    ? prevState.appliedHistory
    : [];
  const nextHistory = [
    { tag: selectedTag, at: now },
    ...history.filter((h) => h.tag !== selectedTag),
  ].slice(0, APPLY_HISTORY_LIMIT);

  writeJson(settings.statePath, {
    ...prevState,
    selectedTag,
    previousTag,
    appliedAt: now,
    rulesAppliedAt: now,
    appliedHistory: nextHistory,
  });
}

async function applyClientSharedProxy() {
  const clientSettings = readClientSettings();
  if (!clientSettings.sharedProxyEnabled || !clientSettings.sharedProxy) {
    return false;
  }

  const generated = buildGatewayConfig(
    { outbounds: [], customRules: [] },
    "",
  );
  writeSingboxConfig(generated);
  await startSingbox();
  pushLog(
    "info",
    `Mac client uses shared gateway proxy ${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`,
  );
  return true;
}

async function applyClientDirectProxy() {
  const generated = buildGatewayConfig(
    { outbounds: [], customRules: [] },
    "",
  );
  writeSingboxConfig(generated);
  await startSingbox();
  pushLog("info", "Mac client routes local proxy directly");
  return true;
}

function handleLogsStream(req, res) {
  res.writeHead(200, {
    "content-type": "text/event-stream; charset=utf-8",
    "cache-control": "no-cache, no-transform",
    connection: "keep-alive",
    "x-accel-buffering": "no",
  });

  for (const entry of logBuffer.slice(-200)) {
    res.write(`data: ${JSON.stringify(entry)}\n\n`);
  }

  const subscriber = (entry) => {
    res.write(`data: ${JSON.stringify(entry)}\n\n`);
  };
  logSubscribers.add(subscriber);

  const keepalive = setInterval(() => {
    try {
      res.write(": ping\n\n");
    } catch {}
  }, 15000);

  req.on("close", () => {
    clearInterval(keepalive);
    logSubscribers.delete(subscriber);
  });
}

async function handleApi(req, res) {
  if (req.method === "GET" && req.url === "/api/state") {
    return sendJson(res, 200, publicState());
  }

  if (req.method === "GET" && req.url === "/api/shared-proxy") {
    return sendJson(
      res,
      200,
      buildSharedProxyInfo({
        appMode: settings.appMode,
        proxyPort: settings.proxyPort,
        running: Boolean(singboxProcess),
        hostHeader: req.headers.host,
        sharedProxyHost: settings.sharedProxyHost,
      }),
    );
  }

  if (req.method === "GET" && req.url === "/api/config") {
    const config = readSingboxConfig();
    return sendJson(res, 200, { success: true, config });
  }

  if (req.method === "GET" && req.url === "/api/logs") {
    return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) });
  }

  if (req.method === "GET" && req.url === "/api/logs/stream") {
    return handleLogsStream(req, res);
  }

  if (req.method === "GET" && req.url === "/api/traffic/stream") {
    res.writeHead(200, {
      "content-type": "text/event-stream; charset=utf-8",
      "cache-control": "no-cache, no-transform",
      connection: "keep-alive",
      "x-accel-buffering": "no",
    });
    for (const entry of trafficBuffer.slice(-200)) {
      res.write(`data: ${JSON.stringify(entry)}\n\n`);
    }
    const sub = (entry) => res.write(`data: ${JSON.stringify(entry)}\n\n`);
    trafficSubscribers.add(sub);
    const keepalive = setInterval(() => {
      try {
        res.write(": ping\n\n");
      } catch {}
    }, 15000);
    req.on("close", () => {
      clearInterval(keepalive);
      trafficSubscribers.delete(sub);
    });
    return;
  }

  if (req.method === "DELETE" && req.url === "/api/traffic") {
    trafficBuffer.splice(0);
    return sendJson(res, 200, { success: true });
  }

  if (req.method === "GET" && req.url === "/api/direct-cache") {
    const members = listDirectBypass();
    return sendJson(res, 200, {
      success: true,
      count: members.length,
      available: IPSET_AVAILABLE,
      members,
    });
  }

  if (req.method === "DELETE" && req.url === "/api/direct-cache") {
    flushDirectBypass();
    return sendJson(res, 200, { success: true });
  }

  if (req.method === "POST" && req.url === "/api/bypass") {
    const body = await readBody(req);
    const enabled = Boolean(body.enabled);
    const prevState = readJson(settings.statePath, {});
    writeJson(settings.statePath, { ...prevState, bypassMode: enabled });

    // Перегенерируем и применяем конфиг, если sing-box запущен
    if (singboxProcess && prevState.selectedTag) {
      const cached = readJson(settings.subscriptionCachePath, null);
      if (cached?.config) {
        const customRules = readJson(settings.customRulesPath, []);
        const generated = buildGatewayConfig(
          { ...cached.config, customRules },
          prevState.selectedTag,
          { bypassAll: enabled },
        );
        writeSingboxConfig(generated);
        await startSingbox();
        pushLog(
          "info",
          enabled
            ? "Режим обхода включён — весь трафик идёт напрямую"
            : "Режим обхода отключён — правила маршрутизации восстановлены",
        );
      }
    }
    return sendJson(res, 200, { success: true, bypassMode: enabled });
  }

  if (req.method === "GET" && req.url === "/api/rules") {
    return sendJson(res, 200, {
      success: true,
      rules: readJson(settings.customRulesPath, []),
    });
  }

  if (req.method === "PUT" && req.url === "/api/rules") {
    const body = await readBody(req);
    const rules = normalizeCustomRules(body.rules);
    writeJson(settings.customRulesPath, rules);
    const prevState = readJson(settings.statePath, {});
    writeJson(settings.statePath, {
      ...prevState,
      rulesUpdatedAt: new Date().toISOString(),
    });
    return sendJson(res, 200, { success: true, rules });
  }

  if (req.method === "GET" && req.url === "/api/rules/conflicts") {
    const rules = readJson(settings.customRulesPath, []);
    return sendJson(res, 200, {
      success: true,
      conflicts: detectRuleConflicts(rules),
    });
  }

  if (req.method === "GET" && req.url === "/api/device-rules") {
    const deviceProfiles = readDeviceProfiles();
    return sendJson(res, 200, {
      success: true,
      deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles),
    });
  }

  if (req.method === "PUT" && req.url === "/api/device-rules") {
    const body = await readBody(req);
    const rules = normalizeDeviceRules(body.deviceRules);
    const devices = [];
    for (const rule of rules) {
      rule.sourceIps.forEach((ip, index) => {
        devices.push({
          id: `${rule.id}-${index}`,
          name: rule.name,
          enabled: rule.enabled,
          ip,
          mode: rule.outbound,
        });
      });
    }
    const profiles = writeDeviceProfiles({
      defaultTransparentMode: "vpn",
      proxyDefaultMode: "vpn",
      devices,
    });
    const prevState = readJson(settings.statePath, {});
    writeJson(settings.statePath, {
      ...prevState,
      devicesUpdatedAt: new Date().toISOString(),
    });
    return sendJson(res, 200, {
      success: true,
      ...profiles,
      deviceRules: legacyDeviceRulesFromProfiles(profiles),
    });
  }

  if (req.method === "GET" && req.url === "/api/devices") {
    const profiles = readDeviceProfiles();
    return sendJson(res, 200, { success: true, ...profiles });
  }

  if (req.method === "PUT" && req.url === "/api/devices") {
    const body = await readBody(req);
    const input = body.devicesConfig || {
      defaultTransparentMode: body.defaultTransparentMode || body.defaultMode,
      proxyDefaultMode: body.proxyDefaultMode,
      devices: body.devices,
    };
    const profiles = writeDeviceProfiles(input);
    const sourceBypassResult = syncTproxySourceBypass(profiles);
    if (!sourceBypassResult.success) {
      pushLog(
        "warning",
        `Не удалось применить bypass устройств в iptables: ${sourceBypassResult.error}`,
      );
    }
    const prevState = readJson(settings.statePath, {});
    const devicesUpdatedAt = new Date().toISOString();
    writeJson(settings.statePath, {
      ...prevState,
      devicesUpdatedAt,
    });
    return sendJson(res, 200, {
      success: true,
      ...profiles,
      sourceBypassCidrs: sourceBypassCidrs(profiles),
      sourceBypassResult,
      devicesUpdatedAt,
    });
  }

  if (req.method === "GET" && req.url === "/api/client-settings") {
    return sendJson(res, 200, {
      success: true,
      clientSettings: readClientSettings(),
    });
  }

  if (req.method === "PUT" && req.url === "/api/client-settings") {
    const body = await readBody(req);
    const clientSettings = writeClientSettings(body.clientSettings || body);
    const prevState = readJson(settings.statePath, {});

    if (settings.appMode === "client") {
      if (clientSettings.sharedProxyEnabled) {
        await applyClientSharedProxy();
      } else if (clientSettings.homeBypassEnabled) {
        await applyClientDirectProxy();
      } else if (
        prevState.selectedTag &&
        readJson(settings.subscriptionCachePath, null)?.config
      ) {
        await applySelectedServer(prevState.selectedTag);
      } else {
        await stopSingbox();
        removeSingboxConfig();
      }
    }

    return sendJson(res, 200, {
      success: true,
      clientSettings,
      singboxRunning: Boolean(singboxProcess),
    });
  }

  if (req.method === "POST" && req.url === "/api/client-settings/shared-proxy/check") {
    const body = await readBody(req);
    const url = String(body.url || "").trim();
    if (!url) {
      return sendJson(res, 400, {
        success: false,
        error: "Укажите адрес gateway",
      });
    }

    const patch = await checkSharedProxyGateway(url);
    const clientSettings = writeClientSettings({
      ...readClientSettings(),
      ...patch,
      homeBypassEnabled: false,
    });

    if (settings.appMode === "client") {
      await applyClientSharedProxy();
    }

    return sendJson(res, 200, {
      success: true,
      clientSettings,
      singboxRunning: Boolean(singboxProcess),
    });
  }

  if (req.method === "GET" && req.url === "/api/rule-sets") {
    return sendJson(res, 200, {
      success: true,
      ruleSets: readJson(settings.customRuleSetsPath, []),
    });
  }

  if (req.method === "PUT" && req.url === "/api/rule-sets") {
    const body = await readBody(req);
    const rawSets = Array.isArray(body.ruleSets) ? body.ruleSets : [];
    const normalized = rawSets
      .filter((rs) => rs && rs.tag && rs.url)
      .map((rs) => ({
        tag: String(rs.tag).trim(),
        url: String(rs.url).trim(),
        format: rs.format === "source" ? "source" : "binary",
      }))
      .filter((rs) => RULE_SET_TAG_RE.test(rs.tag));
    writeJson(settings.customRuleSetsPath, normalized);
    return sendJson(res, 200, { success: true, ruleSets: normalized });
  }

  if (req.method === "POST" && req.url === "/api/rule-sets/lookup") {
    const body = await readBody(req);
    const url = String(body.url || "").trim();
    const tag = String(body.tag || "").trim();
    if (!url)
      return sendJson(res, 400, { success: false, error: "Укажите url" });

    // Кеш — файл рядом с custom-rule-sets.json
    // Используем crypto hash чтобы избежать коллизий при одинаковом префиксе URL
    const cacheKey = crypto.createHash("sha1").update(url).digest("hex");
    const cacheFile = path.join(
      settings.dataDir,
      `ruleset-cache-${cacheKey}.json`,
    );
    const CACHE_TTL_MS = 3 * 60 * 60 * 1000; // 3 часа

    if (fs.existsSync(cacheFile)) {
      try {
        const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
        if (Date.now() - new Date(cached.cachedAt).getTime() < CACHE_TTL_MS) {
          return sendJson(res, 200, { success: true, ...cached });
        }
      } catch {}
    }

    // Скачать .srs во временный файл
    const tmpSrs = path.join(os.tmpdir(), `singbox-rs-${Date.now()}.srs`);
    const tmpJson = tmpSrs.replace(".srs", ".json");
    try {
      const downloadedFrom = await downloadFile(url, tmpSrs);

      // Декомпилировать через sing-box rule-set decompile
      const dec = spawnSync(
        "sing-box",
        ["rule-set", "decompile", "--output", tmpJson, tmpSrs],
        {
          timeout: 15000,
          encoding: "utf8",
        },
      );

      if (dec.error) {
        return sendJson(res, 200, {
          success: false,
          error: `sing-box не найден или не запустился: ${dec.error.message}`,
        });
      }

      if (dec.status !== 0) {
        return sendJson(res, 200, {
          success: false,
          error: `sing-box decompile завершился с ошибкой: ${dec.stderr || "неизвестная ошибка"}`,
        });
      }

      const raw = JSON.parse(fs.readFileSync(tmpJson, "utf8"));
      // Плоский список записей из всех rules
      const rules = Array.isArray(raw.rules) ? raw.rules : [];
      const entries = [];
      for (const rule of rules) {
        if (Array.isArray(rule.domain))
          entries.push(
            ...rule.domain.map((v) => ({ type: "domain", value: v })),
          );
        if (Array.isArray(rule.domain_suffix))
          entries.push(
            ...rule.domain_suffix.map((v) => ({ type: "suffix", value: v })),
          );
        if (Array.isArray(rule.domain_keyword))
          entries.push(
            ...rule.domain_keyword.map((v) => ({ type: "keyword", value: v })),
          );
        if (Array.isArray(rule.ip_cidr))
          entries.push(
            ...rule.ip_cidr.map((v) => ({ type: "cidr", value: v })),
          );
        if (Array.isArray(rule.domain_regex))
          entries.push(
            ...rule.domain_regex.map((v) => ({ type: "regex", value: v })),
          );
      }

      const stats = {
        domain: entries.filter((e) => e.type === "domain").length,
        suffix: entries.filter((e) => e.type === "suffix").length,
        keyword: entries.filter((e) => e.type === "keyword").length,
        cidr: entries.filter((e) => e.type === "cidr").length,
        regex: entries.filter((e) => e.type === "regex").length,
        total: entries.length,
      };

      const result = {
        tag,
        url,
        downloadedFrom,
        entries,
        stats,
        cachedAt: new Date().toISOString(),
      };
      writeJson(cacheFile, result);
      return sendJson(res, 200, { success: true, ...result });
    } catch (err) {
      return sendJson(res, 200, { success: false, error: err.message });
    } finally {
      for (const f of [tmpSrs, tmpJson]) {
        try {
          fs.unlinkSync(f);
        } catch {}
      }
    }
  }

  if (req.method === "GET" && req.url === "/api/rule-sets/sagernet-catalog") {
    const cacheFile = path.join(
      settings.dataDir,
      "sagernet-catalog-cache.json",
    );
    const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 часа

    if (fs.existsSync(cacheFile)) {
      try {
        const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
        if (Date.now() - new Date(cached.cachedAt).getTime() < CACHE_TTL_MS) {
          return sendJson(res, 200, { success: true, ...cached });
        }
      } catch {}
    }

    try {
      const headers = { "User-Agent": "vpn-proxy-app" };
      const [gsRes, giRes] = await Promise.all([
        fetch(
          "https://api.github.com/repos/SagerNet/sing-geosite/git/trees/rule-set?recursive=1",
          { headers },
        ),
        fetch(
          "https://api.github.com/repos/SagerNet/sing-geoip/git/trees/rule-set?recursive=1",
          { headers },
        ),
      ]);
      if (!gsRes.ok || !giRes.ok) {
        throw new Error(
          `GitHub API недоступен: geosite=${gsRes.status}, geoip=${giRes.status}`,
        );
      }
      const gsData = await gsRes.json();
      const giData = await giRes.json();

      const geosite = (gsData.tree || [])
        .filter((f) => f.path.endsWith(".srs"))
        .map((f) => f.path.replace(".srs", ""))
        .sort();
      const geoip = (giData.tree || [])
        .filter((f) => f.path.endsWith(".srs"))
        .map((f) => f.path.replace(".srs", ""))
        .sort();

      if (!geosite.length && !geoip.length) {
        throw new Error("GitHub API вернул пустой каталог rule-set");
      }

      const result = { geosite, geoip, cachedAt: new Date().toISOString() };
      writeJson(cacheFile, result);
      return sendJson(res, 200, { success: true, ...result });
    } catch (err) {
      const result = {
        ...FALLBACK_RULE_SET_CATALOG,
        cachedAt: new Date().toISOString(),
        fallback: true,
        warning: `GitHub каталог не загрузился, показан встроенный список: ${err.message}`,
      };
      return sendJson(res, 200, { success: true, ...result });
    }
  }

  if (req.method === "POST" && req.url === "/api/route/check") {
    const body = await readBody(req);
    const host = String(body.host || "").trim();
    let ip = String(body.ip || "").trim();
    const port =
      body.port !== undefined && body.port !== ""
        ? Number(body.port)
        : undefined;
    const network = String(body.network || "").trim() || undefined;
    const sourceIp = String(body.sourceIp || "").trim() || undefined;
    const inbound = String(body.inbound || "").trim() || undefined;

    if (!host && !ip) {
      return sendJson(res, 400, {
        success: false,
        error: "Укажите домен или IP",
      });
    }

    let resolvedFrom = null;
    if (!ip && host) {
      const resolved = await resolveHost(host);
      if (resolved) {
        ip = resolved;
        resolvedFrom = host;
      }
    }

    const rules = readJson(settings.customRulesPath, []);
    const state = readJson(settings.statePath, {});
    const vpnTag = state.selectedTag || "vpn-out";
    const result = matchRoute({ host, ip, port, network, sourceIp, inbound }, rules, {
      routingRuDirect: settings.routingRuDirect,
      vpnTag,
      deviceProfiles: readDeviceProfiles(),
    });

    return sendJson(res, 200, {
      success: true,
      result,
      resolvedIp: ip || null,
      resolvedFrom,
    });
  }

  if (req.method === "POST" && req.url === "/api/servers/ping") {
    const body = await readBody(req);
    const host = String(body.host || "").trim();
    const port = Number(body.port);
    if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
      return sendJson(res, 400, {
        success: false,
        error: "Требуются host и port",
      });
    }
    const result = await tcpPing(host, port, Number(body.timeout) || 3000);
    return sendJson(res, 200, { success: true, ...result });
  }

  if (req.method === "POST" && req.url === "/api/servers/ping-all") {
    const cached = readJson(settings.subscriptionCachePath, null);
    const state = readJson(settings.statePath, {});
    const servers = state.servers || cached?.servers || [];
    const results = await Promise.all(
      servers.map(async (server) => {
        const ping = await tcpPing(server.server, server.server_port, 3000);
        return {
          tag: server.tag,
          ...ping,
          checkedAt: new Date().toISOString(),
        };
      }),
    );
    return sendJson(res, 200, { success: true, results });
  }

  if (req.method === "POST" && req.url === "/api/config/validate") {
    const cached = readJson(settings.subscriptionCachePath, null);
    const customRules = readJson(settings.customRulesPath, []);
    const stateData = readJson(settings.statePath, {});
    const tag = stateData.selectedTag;

    if (!cached?.config) {
      return sendJson(res, 200, {
        success: true,
        valid: false,
        error: "Подписка не загружена",
      });
    }
    if (!tag) {
      return sendJson(res, 200, {
        success: true,
        valid: false,
        error: "Сервер не выбран",
      });
    }

    try {
      buildGatewayConfig({ ...cached.config, customRules }, tag);
    } catch (err) {
      return sendJson(res, 200, {
        success: true,
        valid: false,
        error: err.message,
      });
    }

    if (fs.existsSync(settings.configPath)) {
      try {
        checkSingboxConfig();
        return sendJson(res, 200, { success: true, valid: true });
      } catch (err) {
        return sendJson(res, 200, {
          success: true,
          valid: false,
          error: err.message,
        });
      }
    }
    return sendJson(res, 200, {
      success: true,
      valid: true,
      note: "Конфиг собирается без ошибок (sing-box check не выполнен — нет файла)",
    });
  }

  if (req.method === "POST" && req.url === "/api/apply/rollback") {
    const stateData = readJson(settings.statePath, {});
    const target = stateData.previousTag;
    if (!target) {
      return sendJson(res, 400, {
        success: false,
        error: "Нет предыдущего сервера для отката",
      });
    }
    await applySelectedServer(target);
    return sendJson(res, 200, { success: true, selectedTag: target });
  }

  if (req.method === "POST" && req.url === "/api/subscription/fetch") {
    const body = await readBody(req);
    const url = String(body.url || "").trim();
    if (!url)
      return sendJson(res, 400, {
        success: false,
        error: "Укажите subscription URL",
      });

    const parsed = await fetchSubscription(url);
    writeJson(settings.subscriptionCachePath, { url, ...parsed });

    const prevState = readJson(settings.statePath, {});
    writeJson(settings.statePath, {
      ...prevState,
      subscriptionUrl: url,
      servers: parsed.servers,
      userInfo: parsed.userInfo,
      fetchedAt: parsed.fetchedAt,
    });

    return sendJson(res, 200, { success: true, ...parsed });
  }

  if (req.method === "DELETE" && req.url === "/api/subscription") {
    if (fs.existsSync(settings.subscriptionCachePath))
      fs.rmSync(settings.subscriptionCachePath);
    const prevState = readJson(settings.statePath, {});
    delete prevState.subscriptionUrl;
    delete prevState.servers;
    delete prevState.userInfo;
    delete prevState.fetchedAt;
    delete prevState.selectedTag;
    delete prevState.appliedAt;
    writeJson(settings.statePath, prevState);
    await stopSingbox();
    removeSingboxConfig();
    pushLog("info", "Подписка удалена, sing-box остановлен");
    return sendJson(res, 200, { success: true });
  }

  if (req.method === "POST" && req.url === "/api/apply") {
    const body = await readBody(req);
    const selectedTag = String(body.selectedTag || "").trim();
    if (!selectedTag)
      return sendJson(res, 400, {
        success: false,
        error: "selectedTag обязателен",
      });

    if (settings.appMode === "client") {
      writeClientSettings({
        ...readClientSettings(),
        homeBypassEnabled: false,
        sharedProxyEnabled: false,
      });
    }

    await applySelectedServer(selectedTag);
    return sendJson(res, 200, {
      success: true,
      selectedTag,
      configPath: settings.configPath,
      singboxRunning: Boolean(singboxProcess),
    });
  }

  if (req.method === "POST" && req.url === "/api/singbox/stop") {
    await stopSingbox();
    pushLog("info", "sing-box остановлен пользователем");
    return sendJson(res, 200, { success: true, singboxRunning: false });
  }

  if (req.method === "POST" && req.url === "/api/singbox/restart") {
    if (!fs.existsSync(settings.configPath)) {
      return sendJson(res, 400, {
        success: false,
        error: "Конфиг отсутствует — сначала примените сервер",
      });
    }
    await startSingbox();
    pushLog("info", "sing-box перезапущен пользователем");
    return sendJson(res, 200, {
      success: true,
      singboxRunning: Boolean(singboxProcess),
    });
  }

  if (req.method === "POST" && req.url === "/api/singbox/clear") {
    await stopSingbox();
    removeSingboxConfig();
    const prevState = readJson(settings.statePath, {});
    delete prevState.selectedTag;
    delete prevState.appliedAt;
    writeJson(settings.statePath, prevState);
    pushLog("info", "Конфиг sing-box удалён, процесс остановлен");
    return sendJson(res, 200, { success: true, singboxRunning: false });
  }

  return sendJson(res, 404, { success: false, error: "Не найдено" });
}

const mime = {
  ".html": "text/html; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".svg": "image/svg+xml",
  ".json": "application/json; charset=utf-8",
};

function serveStatic(req, res) {
  const requestPath = new URL(req.url, `http://localhost:${settings.port}`)
    .pathname;
  const cleanPath = requestPath === "/" ? "/index.html" : requestPath;
  const filePath = path.resolve(settings.distDir, `.${cleanPath}`);
  const distRoot = path.resolve(settings.distDir);

  if (!filePath.startsWith(distRoot)) {
    res.writeHead(403);
    return res.end("Forbidden");
  }

  const finalPath =
    fs.existsSync(filePath) && fs.statSync(filePath).isFile()
      ? filePath
      : path.join(settings.distDir, "index.html");

  const ext = path.extname(finalPath);
  res.writeHead(200, {
    "content-type": mime[ext] || "application/octet-stream",
  });
  fs.createReadStream(finalPath).pipe(res);
}

const server = http.createServer(async (req, res) => {
  try {
    if (req.url?.startsWith("/api/")) {
      return await handleApi(req, res);
    }
    return serveStatic(req, res);
  } catch (error) {
    console.error("[control] request failed", error);
    return sendJson(res, 500, {
      success: false,
      error: error.message || String(error),
    });
  }
});

process.on("SIGTERM", async () => {
  await stopSingbox();
  process.exit(0);
});

process.on("SIGINT", async () => {
  await stopSingbox();
  process.exit(0);
});

const sourceBypassStartup = syncTproxySourceBypass(readDeviceProfiles());
if (!sourceBypassStartup.success) {
  pushLog(
    "warning",
    `Не удалось применить bypass устройств в iptables: ${sourceBypassStartup.error}`,
  );
}

// При старте пробуем подхватить уже запущенный sing-box
const existingPid = readSingboxPid();
if (existingPid && isPidAlive(existingPid)) {
  attachExistingSingbox(existingPid);
} else {
  removeSingboxPid();

  // Если конфиг отсутствует (например после передплоя), пробуем пересобрать из кэша
  if (!fs.existsSync(settings.configPath)) {
    const stateData = readJson(settings.statePath, {});
    const cached = readJson(settings.subscriptionCachePath, null);
    if (stateData.selectedTag && cached?.config) {
      try {
        const customRules = readJson(settings.customRulesPath, []);
        const generated = buildGatewayConfig(
          { ...cached.config, customRules },
          stateData.selectedTag,
          { bypassAll: Boolean(stateData.bypassMode) },
        );
        writeSingboxConfig(generated);
        pushLog(
          "info",
          `Конфиг sing-box восстановлен из кэша (сервер: ${stateData.selectedTag})`,
        );
      } catch (err) {
        pushLog(
          "error",
          `Не удалось восстановить конфиг sing-box: ${err.message}`,
        );
      }
    }
  }

  await startSingbox().catch((error) => {
    console.warn(`[control] sing-box не запущен: ${error.message}`);
    pushLog("error", `sing-box не запущен при старте: ${error.message}`);
  });
}

server.listen(settings.port, "0.0.0.0", () => {
  console.log(`[control] gateway UI слушает :${settings.port}`);
});
