Ctrl K
ComunidadGuías…

src-tauri/src/import.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Импорт профиля из share-ссылки или JSON.
//!
//! Поддержаны ссылки: `hysteria2://`/`hy2://`, `vless://`, `trojan://`,
//! `tuic://`, `ss://`, `vmess://`, а также JSON sing-box (одиночный outbound
//! или полный конфиг с `outbounds`). Подписки — Phase 6.

use crate::models::{Outbound, TlsOpts, Transport};
use base64::{engine::general_purpose, Engine};
use serde_json::Value;
use std::collections::HashMap;
use url::Url;

type Params = HashMap<String, String>;

/// Разбирает ввод (ссылка или JSON) в `(имя, outbound)`.
pub fn parse(input: &str) -> Result<(String, Outbound), String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err("пустой ввод".into());
    }
    if trimmed.starts_with('{') || trimmed.starts_with('[') {
        from_json(trimmed)
    } else {
        from_link(trimmed)
    }
}

// --- ссылки ------------------------------------------------------------------

fn from_link(s: &str) -> Result<(String, Outbound), String> {
    // vpn:// — контейнер Amnezia (qCompress+base64), разбираем отдельно
    if s.starts_with("vpn://") {
        return from_amnezia(s);
    }
    // ss:// и vmess:// разбираем вручную (base64 ломает обычный URL-парсер)
    if let Some(rest) = s.strip_prefix("ss://") {
        return parse_shadowsocks(rest);
    }
    if let Some(rest) = s.strip_prefix("vmess://") {
        return parse_vmess(rest);
    }

    let url = Url::parse(s).map_err(|e| format!("ссылка не распознана: {e}"))?;
    match url.scheme() {
        "hysteria2" | "hy2" => parse_hysteria2(&url),
        "vless" => parse_vless(&url),
        "trojan" => parse_trojan(&url),
        "tuic" => parse_tuic(&url),
        other => Err(format!(
            "протокол ссылки «{other}» пока не поддерживается"
        )),
    }
}

fn parse_hysteria2(url: &Url) -> Result<(String, Outbound), String> {
    let server = host_of(url)?;
    let port = url.port().unwrap_or(443);
    let password = decode(url.username());
    let p = params_of(url);

    Ok((
        name_of(url, || format!("Hysteria2 {server}")),
        Outbound::Hysteria2 {
            server,
            port,
            password,
            sni: p.get("sni").or_else(|| p.get("peer")).cloned().filter(non_empty),
            insecure: is_true(p.get("insecure").or_else(|| p.get("allowInsecure"))),
            obfs_password: p
                .get("obfs-password")
                .or_else(|| p.get("obfs_password"))
                .cloned()
                .filter(non_empty),
            up_mbps: p.get("up").or_else(|| p.get("upmbps")).and_then(|v| parse_mbps(v)),
            down_mbps: p.get("down").or_else(|| p.get("downmbps")).and_then(|v| parse_mbps(v)),
        },
    ))
}

fn parse_vless(url: &Url) -> Result<(String, Outbound), String> {
    let server = host_of(url)?;
    let port = url.port().unwrap_or(443);
    let uuid = decode(url.username());
    let p = params_of(url);

    Ok((
        name_of(url, || format!("VLESS {server}")),
        Outbound::Vless {
            server,
            port,
            uuid,
            flow: p.get("flow").cloned().filter(non_empty),
            tls: build_tls(&p, true),
            transport: build_transport(&p),
        },
    ))
}

fn parse_trojan(url: &Url) -> Result<(String, Outbound), String> {
    let server = host_of(url)?;
    let port = url.port().unwrap_or(443);
    let password = decode(url.username());
    let p = params_of(url);

    Ok((
        name_of(url, || format!("Trojan {server}")),
        Outbound::Trojan {
            server,
            port,
            password,
            tls: build_tls(&p, true),
            transport: build_transport(&p),
        },
    ))
}

fn parse_tuic(url: &Url) -> Result<(String, Outbound), String> {
    let server = host_of(url)?;
    let port = url.port().unwrap_or(443);
    let uuid = decode(url.username());
    let password = decode(url.password().unwrap_or(""));
    let p = params_of(url);

    Ok((
        name_of(url, || format!("TUIC {server}")),
        Outbound::Tuic {
            server,
            port,
            uuid,
            password,
            congestion_control: p
                .get("congestion_control")
                .cloned()
                .filter(non_empty),
            tls: build_tls(&p, true),
        },
    ))
}

fn parse_shadowsocks(rest: &str) -> Result<(String, Outbound), String> {
    let (body, name) = match rest.split_once('#') {
        Some((b, n)) => (b, decode(n)),
        None => (rest, String::new()),
    };
    let body = body.split('?').next().unwrap_or(body); // отбрасываем plugin-параметры

    let (method, password, server, port) = if let Some((userinfo, hostport)) = body.rsplit_once('@')
    {
        // SIP002: userinfo = base64(method:password) или открытым текстом
        let creds = b64_decode(userinfo).unwrap_or_else(|| userinfo.to_string());
        let (m, p) = creds
            .split_once(':')
            .ok_or("ss: не удалось разобрать method:password")?;
        let (h, port) = split_hostport(hostport)?;
        (m.to_string(), p.to_string(), h, port)
    } else {
        // legacy: base64(method:password@host:port)
        let decoded = b64_decode(body).ok_or("ss: не удалось декодировать base64")?;
        let (creds, hostport) = decoded
            .rsplit_once('@')
            .ok_or("ss: неверный формат после декодирования")?;
        let (m, p) = creds.split_once(':').ok_or("ss: нет method:password")?;
        let (h, port) = split_hostport(hostport)?;
        (m.to_string(), p.to_string(), h, port)
    };

    let name = if name.is_empty() {
        format!("Shadowsocks {server}")
    } else {
        name
    };
    Ok((
        name,
        Outbound::Shadowsocks {
            server,
            port,
            method,
            password,
        },
    ))
}

fn parse_vmess(rest: &str) -> Result<(String, Outbound), String> {
    let json = b64_decode(rest).ok_or("vmess: не удалось декодировать base64")?;
    let v: Value = serde_json::from_str(&json).map_err(|e| format!("vmess: JSON: {e}"))?;

    let server = v.get("add").and_then(|x| x.as_str()).unwrap_or("").to_string();
    let port = v.get("port").and_then(flexible_u64).unwrap_or(0) as u16;
    let uuid = v.get("id").and_then(|x| x.as_str()).unwrap_or("").to_string();
    let alter_id = v.get("aid").and_then(flexible_u64).unwrap_or(0) as u32;
    let security = v.get("scy").and_then(|x| x.as_str()).map(String::from).filter(non_empty);

    let tls_on = v.get("tls").and_then(|x| x.as_str()).map(|s| s == "tls").unwrap_or(false);
    let host = v.get("host").and_then(|x| x.as_str()).map(String::from).filter(non_empty);
    let mut tls = TlsOpts::default();
    if tls_on {
        tls.enabled = true;
        tls.sni = v
            .get("sni")
            .and_then(|x| x.as_str())
            .map(String::from)
            .filter(non_empty)
            .or_else(|| host.clone());
    }

    let transport = match v.get("net").and_then(|x| x.as_str()) {
        Some("ws") => Some(Transport::Ws {
            path: v.get("path").and_then(|x| x.as_str()).map(String::from).filter(non_empty),
            host,
        }),
        Some("grpc") => Some(Transport::Grpc {
            service_name: v.get("path").and_then(|x| x.as_str()).map(String::from).filter(non_empty),
        }),
        _ => None,
    };

    let name = v
        .get("ps")
        .and_then(|x| x.as_str())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| format!("VMess {server}"));

    Ok((
        name,
        Outbound::Vmess {
            server,
            port,
            uuid,
            alter_id,
            security,
            tls,
            transport,
        },
    ))
}

// --- Amnezia vpn:// -----------------------------------------------------------

/// Декодирует контейнер Amnezia `vpn://` и мапит поддержанный контейнер в Outbound.
/// Формат: base64url(qCompress) = [4 байта BE длина][zlib(JSON)].
fn from_amnezia(s: &str) -> Result<(String, Outbound), String> {
    let blob = s.trim_start_matches("vpn://").trim();
    let bytes = b64_decode_bytes(blob).ok_or("vpn://: не удалось декодировать base64")?;
    if bytes.len() < 5 {
        return Err("vpn://: слишком короткие данные".into());
    }
    // пропускаем 4-байтовый префикс длины qCompress, распаковываем zlib
    let mut decoder = flate2::read::ZlibDecoder::new(&bytes[4..]);
    let mut json = String::new();
    std::io::Read::read_to_string(&mut decoder, &mut json)
        .map_err(|e| format!("vpn://: распаковка не удалась: {e}"))?;
    let cfg: Value = serde_json::from_str(&json).map_err(|e| format!("vpn://: JSON: {e}"))?;

    let default = cfg.get("defaultContainer").and_then(|v| v.as_str()).unwrap_or("");
    let containers = cfg
        .get("containers")
        .and_then(|v| v.as_array())
        .ok_or("vpn://: нет containers")?;
    let container = containers
        .iter()
        .find(|c| c.get("container").and_then(|v| v.as_str()) == Some(default))
        .or_else(|| containers.first())
        .ok_or("vpn://: пустой список containers")?;
    let ctype = container.get("container").and_then(|v| v.as_str()).unwrap_or("");

    let name = cfg
        .get("description")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .or_else(|| cfg.get("hostName").and_then(|v| v.as_str()))
        .unwrap_or("Amnezia")
        .to_string();

    if ctype.contains("xray") {
        let last = container
            .get("xray")
            .and_then(|x| x.get("last_config"))
            .and_then(|v| v.as_str())
            .ok_or("vpn://: нет xray.last_config")?;
        let xray: Value =
            serde_json::from_str(last).map_err(|e| format!("vpn://: xray config: {e}"))?;
        Ok((name, xray_to_outbound(&xray)?))
    } else if ctype.contains("awg") || ctype.contains("wireguard") {
        let awg = container
            .get("awg")
            .or_else(|| container.get("wireguard"))
            .ok_or("vpn://: контейнер без объекта awg/wireguard")?;
        // last_config (JSON-строка) содержит готовый .conf и mtu
        let last: Option<Value> = awg
            .get("last_config")
            .and_then(|v| v.as_str())
            .and_then(|s| serde_json::from_str(s).ok());
        // .conf: awg.config либо last_config.config
        let raw = awg
            .get("config")
            .and_then(|v| v.as_str())
            .map(str::to_string)
            .or_else(|| {
                last.as_ref()?
                    .get("config")
                    .and_then(|v| v.as_str())
                    .map(str::to_string)
            })
            .ok_or("vpn://: не найден .conf (awg.config / last_config.config)")?;
        // DNS из контейнера (конфиг рассчитан на них; вторичный обычно публичный фолбэк)
        let dns1 = cfg.get("dns1").and_then(|v| v.as_str()).unwrap_or("1.1.1.1");
        let dns2 = cfg.get("dns2").and_then(|v| v.as_str()).unwrap_or("1.0.0.1");
        let mut conf = raw
            .replace("$PRIMARY_DNS", dns1)
            .replace("$SECONDARY_DNS", dns2);
        // MTU как у клиента Amnezia (из awg.mtu или last_config.mtu) — если в .conf его нет
        if !conf.contains("MTU") {
            let mtu = awg
                .get("mtu")
                .and_then(flexible_u64)
                .or_else(|| last.as_ref().and_then(|l| l.get("mtu")).and_then(flexible_u64));
            if let Some(m) = mtu {
                conf = conf.replacen("[Interface]\n", &format!("[Interface]\nMTU = {m}\n"), 1);
            }
        }
        let (server, port) = awg_endpoint(&conf).unwrap_or_else(|| {
            let host = cfg.get("hostName").and_then(|v| v.as_str()).unwrap_or("").to_string();
            let port = awg.get("port").and_then(flexible_u64).unwrap_or(0) as u16;
            (host, port)
        });
        Ok((name, Outbound::AmneziaWg { config: conf, server, port }))
    } else if ctype.contains("openvpn") {
        Err("OpenVPN-контейнер не поддерживается (sing-box без OpenVPN)".into())
    } else {
        Err(format!("контейнер Amnezia «{ctype}» пока не поддержан"))
    }
}

/// Берёт первый «боевой» outbound из Xray-конфига и мапит в наш Outbound.
fn xray_to_outbound(xray: &Value) -> Result<Outbound, String> {
    let outbounds = xray
        .get("outbounds")
        .and_then(|v| v.as_array())
        .ok_or("xray: нет outbounds")?;
    let ob = outbounds
        .iter()
        .find(|o| {
            matches!(
                o.get("protocol").and_then(|v| v.as_str()),
                Some("vless") | Some("vmess") | Some("trojan") | Some("shadowsocks")
            )
        })
        .ok_or("xray: нет поддерживаемого outbound (vless/vmess/trojan/ss)")?;

    let proto = ob.get("protocol").and_then(|v| v.as_str()).unwrap_or("");
    let settings = ob.get("settings");
    let stream = ob.get("streamSettings");

    match proto {
        "vless" => {
            let node = vnext_first(settings)?;
            let user = users_first(node)?;
            let (tls, transport) = xray_stream(stream);
            Ok(Outbound::Vless {
                server: xstr(node, "address"),
                port: xport(node),
                uuid: xstr(user, "id"),
                flow: xopt(user, "flow"),
                tls,
                transport,
            })
        }
        "vmess" => {
            let node = vnext_first(settings)?;
            let user = users_first(node)?;
            let (tls, transport) = xray_stream(stream);
            Ok(Outbound::Vmess {
                server: xstr(node, "address"),
                port: xport(node),
                uuid: xstr(user, "id"),
                alter_id: user.get("alterId").and_then(Value::as_u64).unwrap_or(0) as u32,
                security: xopt(user, "security"),
                tls,
                transport,
            })
        }
        "trojan" => {
            let srv = servers_first(settings)?;
            let (tls, transport) = xray_stream(stream);
            Ok(Outbound::Trojan {
                server: xstr(srv, "address"),
                port: xport(srv),
                password: xstr(srv, "password"),
                tls,
                transport,
            })
        }
        "shadowsocks" => {
            let srv = servers_first(settings)?;
            Ok(Outbound::Shadowsocks {
                server: xstr(srv, "address"),
                port: xport(srv),
                method: xstr(srv, "method"),
                password: xstr(srv, "password"),
            })
        }
        other => Err(format!("xray: протокол «{other}» пока не поддержан")),
    }
}

/// streamSettings Xray → (TlsOpts, Transport).
fn xray_stream(stream: Option<&Value>) -> (TlsOpts, Option<Transport>) {
    let Some(stream) = stream else {
        return (TlsOpts::default(), None);
    };
    let security = stream.get("security").and_then(|v| v.as_str()).unwrap_or("none");
    let mut tls = TlsOpts::default();
    match security {
        "reality" => {
            let r = stream.get("realitySettings");
            tls.enabled = true;
            tls.sni = r.and_then(|x| xopt(x, "serverName"));
            tls.fingerprint = r.and_then(|x| xopt(x, "fingerprint"));
            tls.reality_public_key = r.and_then(|x| xopt(x, "publicKey"));
            tls.reality_short_id = r.and_then(|x| xopt(x, "shortId"));
        }
        "tls" => {
            let t = stream.get("tlsSettings");
            tls.enabled = true;
            tls.sni = t.and_then(|x| xopt(x, "serverName"));
            tls.fingerprint = t.and_then(|x| xopt(x, "fingerprint"));
            tls.insecure = t
                .and_then(|x| x.get("allowInsecure"))
                .and_then(Value::as_bool)
                .unwrap_or(false);
        }
        _ => {}
    }

    let network = stream.get("network").and_then(|v| v.as_str()).unwrap_or("tcp");
    let transport = match network {
        "ws" => {
            let ws = stream.get("wsSettings");
            Some(Transport::Ws {
                path: ws.and_then(|x| xopt(x, "path")),
                host: ws
                    .and_then(|x| x.get("headers"))
                    .and_then(|h| h.get("Host"))
                    .and_then(|v| v.as_str())
                    .map(String::from)
                    .filter(|s| !s.is_empty()),
            })
        }
        "grpc" => Some(Transport::Grpc {
            service_name: stream.get("grpcSettings").and_then(|x| xopt(x, "serviceName")),
        }),
        _ => None,
    };
    (tls, transport)
}

fn vnext_first(settings: Option<&Value>) -> Result<&Value, String> {
    settings
        .and_then(|s| s.get("vnext"))
        .and_then(|v| v.as_array())
        .and_then(|a| a.first())
        .ok_or("xray: нет settings.vnext".into())
}

fn servers_first(settings: Option<&Value>) -> Result<&Value, String> {
    settings
        .and_then(|s| s.get("servers"))
        .and_then(|v| v.as_array())
        .and_then(|a| a.first())
        .ok_or("xray: нет settings.servers".into())
}

fn users_first(node: &Value) -> Result<&Value, String> {
    node.get("users")
        .and_then(|v| v.as_array())
        .and_then(|a| a.first())
        .ok_or("xray: нет users".into())
}

fn xstr(v: &Value, key: &str) -> String {
    v.get(key).and_then(|x| x.as_str()).unwrap_or_default().to_string()
}

fn xopt(v: &Value, key: &str) -> Option<String> {
    v.get(key).and_then(|x| x.as_str()).map(String::from).filter(|s| !s.is_empty())
}

fn xport(v: &Value) -> u16 {
    v.get("port")
        .and_then(flexible_u64)
        .unwrap_or(443) as u16
}

/// Достаёт `Endpoint = host:port` из текста AmneziaWG `.conf`.
fn awg_endpoint(conf: &str) -> Option<(String, u16)> {
    for line in conf.lines() {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix("Endpoint") {
            let val = rest
                .trim_start_matches(|c: char| c == '=' || c.is_whitespace())
                .trim();
            let (host, port) = val.rsplit_once(':')?;
            return Some((host.to_string(), port.parse().ok()?));
        }
    }
    None
}

// --- JSON --------------------------------------------------------------------

fn from_json(s: &str) -> Result<(String, Outbound), String> {
    let value: Value = serde_json::from_str(s).map_err(|e| format!("JSON не распознан: {e}"))?;

    let ob = if let Some(arr) = value.get("outbounds").and_then(|v| v.as_array()) {
        arr.iter()
            .find(|o| {
                let t = o.get("type").and_then(|t| t.as_str()).unwrap_or("");
                !matches!(t, "direct" | "block" | "dns" | "selector" | "urltest")
            })
            .ok_or("в конфиге нет подходящего outbound")?
    } else {
        &value
    };

    let outbound = outbound_from_singbox(ob)?;
    let name = ob
        .get("tag")
        .and_then(|t| t.as_str())
        .map(|t| t.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "Импортированный профиль".to_string());

    Ok((name, outbound))
}

fn outbound_from_singbox(o: &Value) -> Result<Outbound, String> {
    let kind = o
        .get("type")
        .and_then(|v| v.as_str())
        .ok_or("у outbound нет поля type")?;
    let server = req_str(o, "server");
    let port = o.get("server_port").and_then(Value::as_u64).unwrap_or(0) as u16;
    let opt = |name: &str| o.get(name).and_then(|v| v.as_str()).map(String::from).filter(non_empty);

    match kind {
        "hysteria2" => {
            let tls = o.get("tls");
            Ok(Outbound::Hysteria2 {
                server,
                port,
                password: req_str(o, "password"),
                sni: tls.and_then(|t| t.get("server_name")).and_then(|v| v.as_str()).map(String::from),
                insecure: tls.and_then(|t| t.get("insecure")).and_then(Value::as_bool).unwrap_or(false),
                obfs_password: o.get("obfs").and_then(|ob| ob.get("password")).and_then(|v| v.as_str()).map(String::from),
                up_mbps: o.get("up_mbps").and_then(Value::as_u64).map(|n| n as u32),
                down_mbps: o.get("down_mbps").and_then(Value::as_u64).map(|n| n as u32),
            })
        }
        "socks" => Ok(Outbound::Socks {
            server,
            port,
            username: opt("username"),
            password: opt("password"),
        }),
        "http" => Ok(Outbound::Http {
            server,
            port,
            username: opt("username"),
            password: opt("password"),
            tls: o.get("tls").and_then(|t| t.get("enabled")).and_then(Value::as_bool).unwrap_or(false),
        }),
        "shadowsocks" => Ok(Outbound::Shadowsocks {
            server,
            port,
            method: req_str(o, "method"),
            password: req_str(o, "password"),
        }),
        "trojan" => Ok(Outbound::Trojan {
            server,
            port,
            password: req_str(o, "password"),
            tls: tls_from_singbox(o.get("tls")),
            transport: transport_from_singbox(o.get("transport")),
        }),
        "vless" => Ok(Outbound::Vless {
            server,
            port,
            uuid: req_str(o, "uuid"),
            flow: opt("flow"),
            tls: tls_from_singbox(o.get("tls")),
            transport: transport_from_singbox(o.get("transport")),
        }),
        "vmess" => Ok(Outbound::Vmess {
            server,
            port,
            uuid: req_str(o, "uuid"),
            alter_id: o.get("alter_id").and_then(Value::as_u64).unwrap_or(0) as u32,
            security: opt("security"),
            tls: tls_from_singbox(o.get("tls")),
            transport: transport_from_singbox(o.get("transport")),
        }),
        "tuic" => Ok(Outbound::Tuic {
            server,
            port,
            uuid: req_str(o, "uuid"),
            password: req_str(o, "password"),
            congestion_control: opt("congestion_control"),
            tls: tls_from_singbox(o.get("tls")),
        }),
        other => Err(format!("тип outbound «{other}» пока не поддерживается")),
    }
}

fn tls_from_singbox(t: Option<&Value>) -> TlsOpts {
    let Some(t) = t else { return TlsOpts::default() };
    let mut tls = TlsOpts {
        enabled: t.get("enabled").and_then(Value::as_bool).unwrap_or(false),
        sni: t.get("server_name").and_then(|v| v.as_str()).map(String::from),
        insecure: t.get("insecure").and_then(Value::as_bool).unwrap_or(false),
        ..Default::default()
    };
    if let Some(alpn) = t.get("alpn").and_then(|v| v.as_array()) {
        tls.alpn = alpn.iter().filter_map(|x| x.as_str().map(String::from)).collect();
    }
    tls.fingerprint = t.get("utls").and_then(|u| u.get("fingerprint")).and_then(|v| v.as_str()).map(String::from);
    if let Some(r) = t.get("reality") {
        tls.reality_public_key = r.get("public_key").and_then(|v| v.as_str()).map(String::from);
        tls.reality_short_id = r.get("short_id").and_then(|v| v.as_str()).map(String::from);
    }
    tls
}

fn transport_from_singbox(t: Option<&Value>) -> Option<Transport> {
    let t = t?;
    match t.get("type").and_then(|v| v.as_str())? {
        "ws" => Some(Transport::Ws {
            path: t.get("path").and_then(|v| v.as_str()).map(String::from),
            host: t.get("headers").and_then(|h| h.get("Host")).and_then(|v| v.as_str()).map(String::from),
        }),
        "grpc" => Some(Transport::Grpc {
            service_name: t.get("service_name").and_then(|v| v.as_str()).map(String::from),
        }),
        _ => None,
    }
}

// --- общие хелперы -----------------------------------------------------------

fn build_tls(p: &Params, default_enabled: bool) -> TlsOpts {
    let security = p.get("security").map(|s| s.to_lowercase());
    let mut enabled = match security.as_deref() {
        Some("tls") | Some("reality") | Some("xtls") => true,
        Some("none") | Some("") => false,
        _ => default_enabled,
    };
    let reality_public_key = p.get("pbk").cloned().filter(non_empty);
    if reality_public_key.is_some() {
        enabled = true;
    }
    if !enabled {
        return TlsOpts::default();
    }
    TlsOpts {
        enabled: true,
        sni: p.get("sni").or_else(|| p.get("peer")).cloned().filter(non_empty),
        insecure: is_true(p.get("insecure").or_else(|| p.get("allowInsecure"))),
        alpn: p
            .get("alpn")
            .map(|a| a.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
            .unwrap_or_default(),
        fingerprint: p.get("fp").cloned().filter(non_empty),
        reality_short_id: p.get("sid").cloned().filter(non_empty),
        reality_public_key,
    }
}

fn build_transport(p: &Params) -> Option<Transport> {
    match p.get("type").map(|s| s.to_lowercase()).as_deref() {
        Some("ws") => Some(Transport::Ws {
            path: p.get("path").cloned().filter(non_empty),
            host: p.get("host").cloned().filter(non_empty),
        }),
        Some("grpc") => Some(Transport::Grpc {
            service_name: p
                .get("serviceName")
                .or_else(|| p.get("servicename"))
                .cloned()
                .filter(non_empty),
        }),
        _ => None,
    }
}

fn params_of(url: &Url) -> Params {
    url.query_pairs().map(|(k, v)| (k.into_owned(), v.into_owned())).collect()
}

fn host_of(url: &Url) -> Result<String, String> {
    Ok(url.host_str().ok_or("в ссылке нет адреса сервера")?.to_string())
}

fn name_of(url: &Url, fallback: impl FnOnce() -> String) -> String {
    url.fragment()
        .map(decode)
        .filter(|s| !s.is_empty())
        .unwrap_or_else(fallback)
}

fn split_hostport(s: &str) -> Result<(String, u16), String> {
    let (host, port) = s.rsplit_once(':').ok_or("ожидался host:port")?;
    let port = port.parse::<u16>().map_err(|_| "неверный порт")?;
    Ok((host.to_string(), port))
}

fn decode(s: &str) -> String {
    percent_encoding::percent_decode_str(s).decode_utf8_lossy().into_owned()
}

fn b64_decode(s: &str) -> Option<String> {
    let s = s.trim().trim_end_matches('=');
    let bytes = general_purpose::STANDARD_NO_PAD
        .decode(s)
        .or_else(|_| general_purpose::URL_SAFE_NO_PAD.decode(s))
        .ok()?;
    String::from_utf8(bytes).ok()
}

/// Декод base64 в сырые байты (для бинарных контейнеров вроде Amnezia).
fn b64_decode_bytes(s: &str) -> Option<Vec<u8>> {
    let s = s.trim().trim_end_matches('=');
    general_purpose::URL_SAFE_NO_PAD
        .decode(s)
        .or_else(|_| general_purpose::STANDARD_NO_PAD.decode(s))
        .ok()
}

fn req_str(o: &Value, key: &str) -> String {
    o.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string()
}

fn flexible_u64(v: &Value) -> Option<u64> {
    v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
}

fn non_empty(s: &String) -> bool {
    !s.is_empty()
}

fn is_true(v: Option<&String>) -> bool {
    matches!(v.map(|s| s.as_str()), Some("1") | Some("true"))
}

/// Парсит ведущее число из строк вида "100", "100 mbps".
fn parse_mbps(s: &str) -> Option<u32> {
    let digits: String = s.trim().chars().take_while(|c| c.is_ascii_digit()).collect();
    digits.parse().ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ss_sip002() {
        let creds = general_purpose::STANDARD_NO_PAD.encode("aes-256-gcm:pass");
        let (name, ob) = parse(&format!("ss://{creds}@1.2.3.4:8388#nodeA")).unwrap();
        assert_eq!(name, "nodeA");
        match ob {
            Outbound::Shadowsocks { server, port, method, password } => {
                assert_eq!((server.as_str(), port), ("1.2.3.4", 8388));
                assert_eq!((method.as_str(), password.as_str()), ("aes-256-gcm", "pass"));
            }
            _ => panic!("expected shadowsocks"),
        }
    }

    #[test]
    fn ss_legacy() {
        let blob = general_purpose::STANDARD_NO_PAD.encode("chacha20-ietf-poly1305:pw@1.2.3.4:8388");
        let (_, ob) = parse(&format!("ss://{blob}#x")).unwrap();
        match ob {
            Outbound::Shadowsocks { method, password, port, .. } => {
                assert_eq!(method, "chacha20-ietf-poly1305");
                assert_eq!(password, "pw");
                assert_eq!(port, 8388);
            }
            _ => panic!("expected shadowsocks"),
        }
    }

    #[test]
    fn vless_reality_ws() {
        let link = "vless://11111111-1111-1111-1111-111111111111@1.2.3.4:443\
            ?security=reality&sni=ex.com&pbk=KEY&sid=ab&type=ws&path=%2Fp&flow=xtls-rprx-vision#myvless";
        let (name, ob) = parse(link).unwrap();
        assert_eq!(name, "myvless");
        match ob {
            Outbound::Vless { server, port, uuid, flow, tls, transport } => {
                assert_eq!((server.as_str(), port), ("1.2.3.4", 443));
                assert_eq!(uuid, "11111111-1111-1111-1111-111111111111");
                assert_eq!(flow.as_deref(), Some("xtls-rprx-vision"));
                assert!(tls.enabled);
                assert_eq!(tls.sni.as_deref(), Some("ex.com"));
                assert_eq!(tls.reality_public_key.as_deref(), Some("KEY"));
                assert_eq!(tls.reality_short_id.as_deref(), Some("ab"));
                match transport {
                    Some(Transport::Ws { path, .. }) => assert_eq!(path.as_deref(), Some("/p")),
                    _ => panic!("expected ws"),
                }
            }
            _ => panic!("expected vless"),
        }
    }

    #[test]
    fn trojan_ws() {
        let (_, ob) = parse("trojan://secret@1.2.3.4:443?sni=ex.com&type=ws&path=%2Fp#t").unwrap();
        match ob {
            Outbound::Trojan { password, tls, transport, .. } => {
                assert_eq!(password, "secret");
                assert!(tls.enabled);
                assert_eq!(tls.sni.as_deref(), Some("ex.com"));
                assert!(matches!(transport, Some(Transport::Ws { .. })));
            }
            _ => panic!("expected trojan"),
        }
    }

    #[test]
    fn tuic_basic() {
        let (_, ob) = parse("tuic://uuidval:pw@1.2.3.4:443?congestion_control=bbr&sni=ex.com#x").unwrap();
        match ob {
            Outbound::Tuic { uuid, password, congestion_control, tls, .. } => {
                assert_eq!((uuid.as_str(), password.as_str()), ("uuidval", "pw"));
                assert_eq!(congestion_control.as_deref(), Some("bbr"));
                assert!(tls.enabled);
            }
            _ => panic!("expected tuic"),
        }
    }

    #[test]
    fn vmess_base64_json() {
        let json = r#"{"v":"2","ps":"myvmess","add":"1.2.3.4","port":"443","id":"uuidv","aid":"0","net":"ws","host":"ex.com","path":"/p","tls":"tls","sni":"ex.com","scy":"auto"}"#;
        let b64 = general_purpose::STANDARD_NO_PAD.encode(json);
        let (name, ob) = parse(&format!("vmess://{b64}")).unwrap();
        assert_eq!(name, "myvmess");
        match ob {
            Outbound::Vmess { server, port, uuid, security, tls, transport, .. } => {
                assert_eq!((server.as_str(), port), ("1.2.3.4", 443));
                assert_eq!(uuid, "uuidv");
                assert_eq!(security.as_deref(), Some("auto"));
                assert!(tls.enabled);
                assert_eq!(tls.sni.as_deref(), Some("ex.com"));
                assert!(matches!(transport, Some(Transport::Ws { .. })));
            }
            _ => panic!("expected vmess"),
        }
    }

    #[test]
    fn hysteria2_link_and_json() {
        let (_, ob) = parse("hysteria2://pw@1.2.3.4:443?sni=ex.com&insecure=1#h").unwrap();
        match ob {
            Outbound::Hysteria2 { password, sni, insecure, .. } => {
                assert_eq!(password, "pw");
                assert_eq!(sni.as_deref(), Some("ex.com"));
                assert!(insecure);
            }
            _ => panic!("expected hysteria2"),
        }

        let js = r#"{"type":"hysteria2","server":"1.2.3.4","server_port":443,"password":"pw","tls":{"server_name":"ex.com"}}"#;
        assert!(matches!(parse(js).unwrap().1, Outbound::Hysteria2 { .. }));
    }

    fn pack_amnezia(amnezia: &str) -> String {
        use std::io::Write;
        let mut enc =
            flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
        enc.write_all(amnezia.as_bytes()).unwrap();
        let zlib = enc.finish().unwrap();
        let mut raw = (amnezia.len() as u32).to_be_bytes().to_vec();
        raw.extend_from_slice(&zlib);
        format!("vpn://{}", general_purpose::URL_SAFE_NO_PAD.encode(&raw))
    }

    #[test]
    fn amnezia_vpn_awg() {
        let conf = "[Interface]\nAddress = 10.8.1.7/32\nDNS = $PRIMARY_DNS, $SECONDARY_DNS\nPrivateKey = KEY\nJc = 5\n[Peer]\nPublicKey = PUB\nEndpoint = 1.2.3.4:34196\nAllowedIPs = 0.0.0.0/0, ::/0\n";
        // реальная структура: .conf и mtu внутри awg.last_config (JSON-строка)
        let last_config = serde_json::json!({ "config": conf, "mtu": "1376" }).to_string();
        let amnezia = serde_json::json!({
            "defaultContainer": "amnezia-awg2",
            "description": "FR-AWG",
            "hostName": "1.2.3.4",
            "dns1": "1.1.1.1",
            "dns2": "8.8.8.8",
            "containers": [ { "container": "amnezia-awg2", "awg": { "last_config": last_config, "port": "34196" } } ]
        })
        .to_string();

        let (name, ob) = parse(&pack_amnezia(&amnezia)).unwrap();
        assert_eq!(name, "FR-AWG");
        match ob {
            Outbound::AmneziaWg { config, server, port } => {
                assert_eq!((server.as_str(), port), ("1.2.3.4", 34196));
                assert!(config.contains("Jc = 5"));
                assert!(config.contains("DNS = 1.1.1.1, 8.8.8.8"));
                assert!(config.contains("MTU = 1376"));
                assert!(!config.contains("$PRIMARY_DNS"));
            }
            _ => panic!("expected amneziawg"),
        }
    }

    #[test]
    fn amnezia_vpn_awg31_preserves_new_engine_fields() {
        let conf = "[Interface]\nAddress = 10.8.1.7/32\nPrivateKey = KEY\nH1 = 100-200\nHeaderProtectionKey = HEADER_KEY\nContentPaddingAddition = 16-64\nRekeyAfterTime = 120-180\nRekeyTimeout = 3-5\nRejectAfterTime = 180-240\nKeepaliveTimeout = 10-15\nMaxHandshakeAttempts = 5-8\nRandomTrailers = on\nDisableCookies = off\n[Peer]\nPublicKey = PUB\nEndpoint = 192.0.2.1:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n";
        let last_config = serde_json::json!({ "config": conf, "mtu": 1376 }).to_string();
        let amnezia = serde_json::json!({
            "defaultContainer": "amnezia-awg3",
            "description": "AWG 3.1",
            "hostName": "192.0.2.1",
            "containers": [{
                "container": "amnezia-awg3",
                "awg": { "last_config": last_config, "port": 51820, "protocol_version": 3 }
            }]
        })
        .to_string();

        let (_, outbound) = parse(&pack_amnezia(&amnezia)).unwrap();
        let Outbound::AmneziaWg { config, server, port } = outbound else {
            panic!("expected amneziawg");
        };
        assert_eq!((server.as_str(), port), ("192.0.2.1", 51820));
        for field in [
            "HeaderProtectionKey = HEADER_KEY",
            "ContentPaddingAddition = 16-64",
            "RekeyAfterTime = 120-180",
            "RekeyTimeout = 3-5",
            "RejectAfterTime = 180-240",
            "KeepaliveTimeout = 10-15",
            "MaxHandshakeAttempts = 5-8",
            "RandomTrailers = on",
            "DisableCookies = off",
        ] {
            assert!(config.contains(field), "missing {field}");
        }
    }

    #[test]
    fn amnezia_vpn_xray_vless() {
        use std::io::Write;
        // встроенный Xray-конфиг (как в контейнере amnezia-xray)
        let xray_inner = r#"{"outbounds":[{"protocol":"vless","settings":{"vnext":[{"address":"1.2.3.4","port":443,"users":[{"id":"abc-uuid","flow":"xtls-rprx-vision"}]}]},"streamSettings":{"network":"tcp","security":"reality","realitySettings":{"fingerprint":"chrome","serverName":"www.example.com","publicKey":"PUBKEY","shortId":"ab12"}}}]}"#;
        let amnezia = serde_json::json!({
            "defaultContainer": "amnezia-xray",
            "description": "TestFR",
            "hostName": "1.2.3.4",
            "containers": [ { "container": "amnezia-xray", "xray": { "last_config": xray_inner } } ]
        })
        .to_string();

        // qCompress: [4 байта BE длина][zlib]
        let mut enc =
            flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
        enc.write_all(amnezia.as_bytes()).unwrap();
        let zlib = enc.finish().unwrap();
        let mut raw = (amnezia.len() as u32).to_be_bytes().to_vec();
        raw.extend_from_slice(&zlib);
        let blob = general_purpose::URL_SAFE_NO_PAD.encode(&raw);

        let (name, ob) = parse(&format!("vpn://{blob}")).unwrap();
        assert_eq!(name, "TestFR");
        match ob {
            Outbound::Vless { server, port, uuid, flow, tls, .. } => {
                assert_eq!((server.as_str(), port), ("1.2.3.4", 443));
                assert_eq!(uuid, "abc-uuid");
                assert_eq!(flow.as_deref(), Some("xtls-rprx-vision"));
                assert!(tls.enabled);
                assert_eq!(tls.sni.as_deref(), Some("www.example.com"));
                assert_eq!(tls.reality_public_key.as_deref(), Some("PUBKEY"));
                assert_eq!(tls.reality_short_id.as_deref(), Some("ab12"));
                assert_eq!(tls.fingerprint.as_deref(), Some("chrome"));
            }
            _ => panic!("expected vless"),
        }
    }
}