import { Base64 } from '@/utils';

// Focused share-link parser for the OutboundFormModal's link-import
// helper. Each parser returns a wire-shape outbound record (the same
// shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
// null when the input doesn't match.
//
// Scope: address + port + auth + remark, plus the network/security
// fields the common vmess:// / vless:// links carry as query params.
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
// present in either the JSON or URL params. xmux and downloadSettings
// round-trip through the `extra` JSON blob. reality shortIds, padding
// obfs key/header/placement, hysteria udphop are still left
// to the user to fill in after import — the legacy Outbound.fromLink
// was ~250 lines of dense edge-case handling we don't need to
// replicate verbatim for the common phone-to-panel workflow.

type Raw = Record<string, unknown>;

// XHTTP knob keys grouped by wire type. Used by both the URL query-param
// (vless/trojan) branch and the vmess JSON branch to consistently pull
// the same set of advanced fields when present. Keep order ~stable to
// match the schema's authoring order so diffs read naturally.
const XHTTP_STRING_KEYS = [
  'xPaddingBytes',
  'xPaddingKey',
  'xPaddingHeader',
  'xPaddingPlacement',
  'xPaddingMethod',
  'sessionIDPlacement',
  'sessionIDKey',
  'sessionIDTable',
  'sessionIDLength',
  'seqPlacement',
  'seqKey',
  'uplinkDataPlacement',
  'uplinkDataKey',
  'scMaxEachPostBytes',
  'scMinPostsIntervalMs',
  'scStreamUpServerSecs',
  'uplinkHTTPMethod',
] as const;
// Legacy share links (pre xray-core #6258) carry sessionPlacement/sessionKey.
// Map them onto the renamed keys so old links still import. Mirrors the
// schema-level migrateLegacyXhttp.
const XHTTP_LEGACY_ALIASES: Record<string, string> = {
  sessionPlacement: 'sessionIDPlacement',
  sessionKey: 'sessionIDKey',
};
const XHTTP_NUMBER_KEYS = [
  'scMaxBufferedPosts',
  'serverMaxHeaderBytes',
  'uplinkChunkSize',
] as const;
const XHTTP_BOOL_KEYS = ['xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader'] as const;
// Nested objects the inbound link bundles into the `extra` JSON blob
// (and vmess JSON carries inline). The outbound form adapter expands
// xmux into the XMUX sub-form (enableXmux) on load.
const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;

function asBool(s: string | null): boolean | undefined {
  if (s === null) return undefined;
  return s === 'true' || s === '1';
}

function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
  // Precedence from lowest to highest: stream-init default →
  // x_padding_bytes snake_case alias → extra JSON payload →
  // explicit camelCase URL param. Apply in that order so each tier
  // overwrites the previous when present.
  const padBytesAlt = params.get('x_padding_bytes');
  if (padBytesAlt !== null && padBytesAlt !== '') {
    xhttp.xPaddingBytes = padBytesAlt;
  }
  // The inbound link bundles advanced xhttp knobs into `extra=<json>`.
  // Decode and merge so re-importing a share link round-trips the full
  // xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
  const extra = params.get('extra');
  if (extra) {
    try {
      const parsed = JSON.parse(extra) as Record<string, unknown>;
      applyXhttpStringFromJson(xhttp, parsed);
      if (parsed.headers && typeof parsed.headers === 'object') {
        xhttp.headers = parsed.headers;
      }
    } catch {
      // malformed extra — silently ignore, the panel can still operate
      // on the rest of the link
    }
  }
  for (const k of XHTTP_STRING_KEYS) {
    const v = params.get(k);
    if (v !== null && v !== '') xhttp[k] = v;
  }
  for (const k of XHTTP_NUMBER_KEYS) {
    const v = params.get(k);
    if (v !== null && v !== '') xhttp[k] = Number(v) || 0;
  }
  for (const k of XHTTP_BOOL_KEYS) {
    const v = params.get(k);
    if (v !== null && v !== '') xhttp[k] = asBool(v);
  }
  // Fill renamed keys from legacy params only when the new key is absent.
  for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
    if (xhttp[renamed] === undefined) {
      const v = params.get(legacy);
      if (v !== null && v !== '') xhttp[renamed] = v;
    }
  }
}

function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
  for (const k of XHTTP_STRING_KEYS) {
    if (typeof json[k] === 'string') xhttp[k] = json[k];
  }
  for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
    if (xhttp[renamed] === undefined && typeof json[legacy] === 'string') {
      xhttp[renamed] = json[legacy];
    }
  }
  for (const k of XHTTP_NUMBER_KEYS) {
    if (typeof json[k] === 'number') xhttp[k] = json[k];
  }
  for (const k of XHTTP_BOOL_KEYS) {
    if (typeof json[k] === 'boolean') xhttp[k] = json[k];
  }
  for (const k of XHTTP_OBJECT_KEYS) {
    const v = json[k];
    if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
  }
}

function buildStream(network: string, security: string): Raw {
  const stream: Raw = { network, security };
  switch (network) {
    case 'tcp':
      stream.tcpSettings = { header: { type: 'none' } };
      break;
    case 'kcp':
      stream.kcpSettings = {
        mtu: 1350,
        tti: 20,
        uplinkCapacity: 5,
        downlinkCapacity: 20,
        cwndMultiplier: 1,
        maxSendingWindow: 2097152,
      };
      break;
    case 'ws':
      stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
      break;
    case 'grpc':
      stream.grpcSettings = { serviceName: '', authority: '', multiMode: false };
      break;
    case 'httpupgrade':
      stream.httpupgradeSettings = { path: '/', host: '', headers: {} };
      break;
    case 'xhttp':
      stream.xhttpSettings = {
        path: '/',
        host: '',
        mode: 'auto',
        headers: {},
        xPaddingBytes: '100-1000',
      };
      break;
    default:
      stream.tcpSettings = { header: { type: 'none' } };
  }
  if (security === 'tls') {
    stream.tlsSettings = {
      serverName: '',
      alpn: [],
      fingerprint: '',
      echConfigList: '',
      verifyPeerCertByName: '',
      pinnedPeerCertSha256: '',
    };
  } else if (security === 'reality') {
    stream.realitySettings = {
      publicKey: '',
      fingerprint: 'chrome',
      serverName: '',
      shortId: '',
      spiderX: '',
      mldsa65Verify: '',
    };
  }
  return stream;
}

function applyTransportParams(stream: Raw, params: URLSearchParams): void {
  const network = stream.network as string;
  const host = params.get('host') ?? '';
  const path = params.get('path') ?? '/';
  switch (network) {
    case 'ws':
      (stream.wsSettings as Raw).host = host;
      (stream.wsSettings as Raw).path = path;
      break;
    case 'grpc': {
      const grpc = stream.grpcSettings as Raw;
      const serviceName = params.get('serviceName') ?? params.get('path') ?? '';
      grpc.serviceName = serviceName;
      grpc.authority = params.get('authority') ?? '';
      grpc.multiMode = params.get('mode') === 'multi';
      break;
    }
    case 'httpupgrade':
      (stream.httpupgradeSettings as Raw).host = host;
      (stream.httpupgradeSettings as Raw).path = path;
      break;
    case 'xhttp': {
      const xhttp = stream.xhttpSettings as Raw;
      xhttp.host = host;
      xhttp.path = path;
      if (params.get('mode')) xhttp.mode = params.get('mode');
      applyXhttpStringFromParams(xhttp, params);
      break;
    }
    case 'tcp':
      // vless/trojan TCP HTTP camouflage rides on header=http+host+path
      if (params.get('headerType') === 'http' || params.get('type') === 'http') {
        (stream.tcpSettings as Raw).header = {
          type: 'http',
          request: {
            version: '1.1',
            method: 'GET',
            path: path.split(',').filter(Boolean),
            headers: host ? { Host: host.split(',').filter(Boolean) } : {},
          },
        };
      }
      break;
  }
}

// The inbound link emits the entire finalmask object as a JSON-encoded
// `fm` query param. Decode and attach to streamSettings so udpHop /
// quicParams / tcp+udp masks round-trip on outbound import.
function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
  const fm = params.get('fm');
  if (!fm) return;
  try {
    const parsed = JSON.parse(fm) as Record<string, unknown>;
    if (parsed && typeof parsed === 'object') {
      sanitizeFinalMaskQuicParams(parsed);
      stream.finalmask = parsed;
    }
  } catch {
    // malformed fm — leave streamSettings.finalmask absent
  }
}

function ensureFinalMask(stream: Raw): Raw {
  if (!stream.finalmask || typeof stream.finalmask !== 'object') stream.finalmask = {};
  return stream.finalmask as Raw;
}

// Rebuild the salamander mask from the standard Hysteria2 obfs pair; an fm=
// password wins. obfs=gecko adds min/maxPacketSize stored as packetSize.
function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
  const obfs = (params.get('obfs') ?? '').toLowerCase();
  const isGecko = obfs === 'gecko';
  if (!isGecko && obfs !== 'salamander') return;
  const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
  if (!password) return;
  let packetSize = '';
  if (isGecko) {
    // Both halves required and numeric, matching the export side; anything
    // else is dropped rather than stored as a malformed range.
    const minSize = (params.get('minPacketSize') ?? '').trim();
    const maxSize = (params.get('maxPacketSize') ?? '').trim();
    const min = Number(minSize);
    const max = Number(maxSize);
    if (
      /^\d+$/.test(minSize) &&
      /^\d+$/.test(maxSize) &&
      Number.isSafeInteger(min) &&
      Number.isSafeInteger(max) &&
      min >= 1 &&
      max >= min &&
      max <= 2048
    ) {
      packetSize = `${min}-${max}`;
    }
  }
  const finalmask = ensureFinalMask(stream);
  const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
  const existing = udp.find(
    (m) => m && typeof m === 'object' && (m as Raw).type === 'salamander',
  ) as Raw | undefined;
  if (existing) {
    const settings = (
      existing.settings && typeof existing.settings === 'object'
        ? existing.settings
        : (existing.settings = {})
    ) as Raw;
    if (typeof settings.password !== 'string' || settings.password.length === 0)
      settings.password = password;
    if (
      packetSize !== '' &&
      !(typeof settings.packetSize === 'string' && settings.packetSize.length > 0)
    )
      settings.packetSize = packetSize;
    return;
  }
  const settings: Raw = { password };
  if (packetSize !== '') settings.packetSize = packetSize;
  finalmask.udp = [...udp, { type: 'salamander', settings }];
}

// Rebuild the UDP port-hopping range from the standard mport param, which the
// generator emits as finalmask.quicParams.udpHop.ports. A range already supplied
// via fm= wins; the client-side interval falls back to the panel's default.
function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
  const ports = firstParam(params, 'mport');
  if (!ports) return;
  const finalmask = ensureFinalMask(stream);
  const quicParams = (
    finalmask.quicParams && typeof finalmask.quicParams === 'object'
      ? finalmask.quicParams
      : (finalmask.quicParams = {})
  ) as Raw;
  const existingHop = quicParams.udpHop as Raw | undefined;
  if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return;
  quicParams.udpHop = { ports, interval: '5-10' };
}

const QUIC_PARAMS_NUMERIC_KEYS = [
  'initStreamReceiveWindow',
  'maxStreamReceiveWindow',
  'initConnectionReceiveWindow',
  'maxConnectionReceiveWindow',
  'maxIdleTimeout',
  'keepAlivePeriod',
  'maxIncomingStreams',
] as const;

const DURATION_SECONDS: Record<string, number> = { ms: 0.001, s: 1, m: 60, h: 3600 };

const QUIC_NUMERIC_MAX = 1e15;

function coerceQuicNumeric(value: unknown): number | null {
  if (typeof value === 'number' && Number.isFinite(value)) {
    return Math.trunc(value);
  }
  if (typeof value === 'string') {
    const asNumber = Number(value);
    if (value.trim() !== '' && Number.isFinite(asNumber)) {
      return Math.trunc(asNumber);
    }
    const duration = /^(-?\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value.trim());
    if (duration) {
      return Math.trunc(Number(duration[1]) * DURATION_SECONDS[duration[2]]);
    }
  }
  return null;
}

function clampQuicNumeric(key: string, n: number): number | null {
  if (n < 0 || n > QUIC_NUMERIC_MAX) return null;
  if (n === 0) return 0;
  if (key === 'keepAlivePeriod') return Math.min(Math.max(n, 2), 60);
  if (key === 'maxIdleTimeout') return Math.min(Math.max(n, 4), 120);
  if (key === 'maxIncomingStreams') return Math.max(n, 8);
  return n;
}

// xray-core rejects the whole config when these quicParams fields are not
// plain integers within its accepted ranges (keepAlivePeriod 0 or 2-60,
// maxIdleTimeout 0 or 4-120, maxIncomingStreams 0 or >= 8), so coerce
// numeric/duration strings, clamp the ranged fields, and drop anything
// unparseable, negative, or absurdly large (#5783). Mirrors the Go parser in
// internal/util/link/outbound.go.
function sanitizeFinalMaskQuicParams(parsed: Record<string, unknown>): void {
  const raw = parsed.quicParams;
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
  const quic = raw as Record<string, unknown>;
  for (const key of QUIC_PARAMS_NUMERIC_KEYS) {
    if (!(key in quic)) continue;
    const coerced = coerceQuicNumeric(quic[key]);
    const clamped = coerced === null ? null : clampQuicNumeric(key, coerced);
    if (clamped === null) {
      delete quic[key];
      continue;
    }
    quic[key] = clamped;
  }
}

function applySecurityParams(stream: Raw, params: URLSearchParams): void {
  if (stream.security === 'tls') {
    const tls = stream.tlsSettings as Raw;
    tls.serverName = params.get('sni') ?? '';
    tls.fingerprint = params.get('fp') ?? '';
    const alpn = params.get('alpn');
    if (alpn) tls.alpn = alpn.split(',');
    tls.echConfigList = params.get('ech') ?? '';
    tls.verifyPeerCertByName = params.get('vcn') ?? '';
    tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
  } else if (stream.security === 'reality') {
    const reality = stream.realitySettings as Raw;
    reality.serverName = params.get('sni') ?? '';
    reality.fingerprint = params.get('fp') ?? 'chrome';
    reality.publicKey = params.get('pbk') ?? '';
    reality.shortId = params.get('sid') ?? '';
    reality.spiderX = params.get('spx') ?? '';
    reality.mldsa65Verify = params.get('pqv') ?? '';
  }
}

function decodeRemark(url: URL): string {
  try {
    return decodeURIComponent(url.hash.replace(/^#/, ''));
  } catch {
    return url.hash.replace(/^#/, '');
  }
}

export function parseVmessLink(link: string): Raw | null {
  if (!link.startsWith('vmess://')) return null;
  try {
    const decoded = Base64.decode(link.slice('vmess://'.length));
    const json = JSON.parse(decoded) as Record<string, unknown>;
    const network = (json.net as string) || 'tcp';
    const security = json.tls === 'tls' ? 'tls' : 'none';
    const stream = buildStream(network, security);
    // Map the vmess JSON's net-specific keys onto the stream branch.
    if (network === 'tcp' && json.type === 'http') {
      (stream.tcpSettings as Raw).header = {
        type: 'http',
        request: {
          version: '1.1',
          method: 'GET',
          path: ((json.path as string) ?? '/').split(',').filter(Boolean),
          headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
        },
      };
    } else if (network === 'ws') {
      (stream.wsSettings as Raw).host = json.host ?? '';
      (stream.wsSettings as Raw).path = json.path ?? '/';
    } else if (network === 'grpc') {
      (stream.grpcSettings as Raw).serviceName = json.path ?? '';
      (stream.grpcSettings as Raw).authority = json.authority ?? '';
      (stream.grpcSettings as Raw).multiMode = json.type === 'multi';
    } else if (network === 'httpupgrade') {
      (stream.httpupgradeSettings as Raw).host = json.host ?? '';
      (stream.httpupgradeSettings as Raw).path = json.path ?? '/';
    } else if (network === 'xhttp') {
      const xhttp = stream.xhttpSettings as Raw;
      xhttp.host = json.host ?? '';
      xhttp.path = json.path ?? '/';
      if (json.mode) xhttp.mode = json.mode;
      applyXhttpStringFromJson(xhttp, json);
    }
    if (security === 'tls') {
      const tls = stream.tlsSettings as Raw;
      tls.serverName = json.sni ?? '';
      tls.fingerprint = json.fp ?? '';
      if (json.alpn) tls.alpn = (json.alpn as string).split(',');
    }

    const port = Number(json.port) || 443;
    const rawScy = (json.scy as string) || 'auto';
    const userSecurity = rawScy === 'none' || rawScy === 'zero' ? 'auto' : rawScy;
    return {
      protocol: 'vmess',
      tag: typeof json.ps === 'string' ? json.ps : '',
      settings: {
        vnext: [
          {
            address: json.add ?? '',
            port,
            users: [{ id: json.id ?? '', security: userSecurity }],
          },
        ],
      },
      streamSettings: stream,
    };
  } catch {
    return null;
  }
}

function parseUrlLink(link: string, expectedProto: string): URL | null {
  try {
    const url = new URL(link);
    if (url.protocol.replace(/:$/, '') !== expectedProto) return null;
    return url;
  } catch {
    return null;
  }
}

export function parseVlessLink(link: string): Raw | null {
  const url = parseUrlLink(link, 'vless');
  if (!url) return null;
  const id = url.username;
  const address = url.hostname;
  const port = Number(url.port) || 443;
  const params = url.searchParams;
  const network = params.get('type') ?? 'tcp';
  const security = (params.get('security') ?? 'none') as string;
  const stream = buildStream(network, security);
  applyTransportParams(stream, params);
  applySecurityParams(stream, params);
  applyFinalMaskParam(stream, params);
  return {
    protocol: 'vless',
    tag: decodeRemark(url),
    settings: {
      address,
      port,
      id,
      flow: params.get('flow') ?? '',
      encryption: params.get('encryption') ?? 'none',
    },
    streamSettings: stream,
  };
}

export function parseTrojanLink(link: string): Raw | null {
  const url = parseUrlLink(link, 'trojan');
  if (!url) return null;
  const password = url.username;
  const address = url.hostname;
  const port = Number(url.port) || 443;
  const params = url.searchParams;
  const network = params.get('type') ?? 'tcp';
  const security = (params.get('security') ?? 'tls') as string;
  const stream = buildStream(network, security);
  applyTransportParams(stream, params);
  applySecurityParams(stream, params);
  applyFinalMaskParam(stream, params);
  return {
    protocol: 'trojan',
    tag: decodeRemark(url),
    settings: {
      servers: [{ address, port, password }],
    },
    streamSettings: stream,
  };
}

export function parseShadowsocksLink(link: string): Raw | null {
  if (!link.startsWith('ss://')) return null;
  // Two link shapes coexist:
  //   modern:  ss://base64(method:password)@host:port#remark
  //   legacy:  ss://base64(method:password@host:port)#remark
  // Try modern first; fall back to legacy decode of the whole userinfo+host.
  let userInfo: string;
  let host: string;
  let port: number;
  let remark = '';
  const hashIndex = link.indexOf('#');
  const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
  if (hashIndex >= 0) {
    try {
      remark = decodeURIComponent(link.slice(hashIndex + 1));
    } catch {
      remark = '';
    }
  }
  const queryIndex = linkNoHash.indexOf('?');
  const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
  const atIndex = core.indexOf('@');
  if (atIndex >= 0) {
    const rawUserInfo = core.slice('ss://'.length, atIndex);
    if (rawUserInfo.includes(':')) {
      // SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64
      // (a literal ':' can't appear in a base64/base64url string).
      try {
        userInfo = decodeURIComponent(rawUserInfo);
      } catch {
        userInfo = rawUserInfo;
      }
    } else {
      try {
        userInfo = Base64.decode(rawUserInfo);
      } catch {
        userInfo = rawUserInfo;
      }
    }
    const hostPort = core.slice(atIndex + 1);
    const colon = hostPort.lastIndexOf(':');
    if (colon < 0) return null;
    host = hostPort.slice(0, colon);
    port = Number(hostPort.slice(colon + 1)) || 443;
  } else {
    let decoded: string;
    try {
      decoded = Base64.decode(core.slice('ss://'.length));
    } catch {
      return null;
    }
    const at = decoded.indexOf('@');
    if (at < 0) return null;
    userInfo = decoded.slice(0, at);
    const hostPort = decoded.slice(at + 1);
    const colon = hostPort.lastIndexOf(':');
    if (colon < 0) return null;
    host = hostPort.slice(0, colon);
    port = Number(hostPort.slice(colon + 1)) || 443;
  }
  const sep = userInfo.indexOf(':');
  const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
  const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
  return {
    protocol: 'shadowsocks',
    tag: remark,
    settings: {
      servers: [{ address: host, port, password, method }],
    },
  };
}

export function parseHysteria2Link(link: string): Raw | null {
  const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2');
  if (!url) return null;
  // hysteria2's auth rides as the URL userinfo. The streamSettings
  // network branch is the dedicated 'hysteria' transport — the modal's
  // newStreamSlice('hysteria') initializer fills in receive-window
  // defaults; we override the user-set fields here.
  const auth = url.username;
  const address = url.hostname;
  const port = Number(url.port) || 443;
  const params = url.searchParams;
  const alpn = params.get('alpn');
  const stream: Raw = {
    network: 'hysteria',
    security: 'tls',
    hysteriaSettings: {
      version: 2,
      auth,
      udpIdleTimeout: 60,
    },
    tlsSettings: {
      serverName: params.get('sni') ?? '',
      alpn: alpn ? alpn.split(',') : ['h3'],
      fingerprint: params.get('fp') ?? '',
      echConfigList: params.get('ech') ?? '',
      verifyPeerCertByName: params.get('vcn') ?? '',
      pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
    },
  };
  applyFinalMaskParam(stream, params);
  applyHysteria2Obfs(stream, params);
  applyHysteria2Hop(stream, params);
  return {
    protocol: 'hysteria',
    tag: decodeRemark(url),
    settings: { address, port, version: 2 },
    streamSettings: stream,
  };
}

function firstParam(params: URLSearchParams, ...keys: string[]): string | null {
  for (const k of keys) {
    const v = params.get(k);
    if (v !== null && v !== '') return v;
  }
  return null;
}

export function parseWireguardLink(link: string): Raw | null {
  const url = parseUrlLink(link, 'wireguard') ?? parseUrlLink(link, 'wg');
  if (!url) return null;
  let secretKey: string;
  try {
    secretKey = decodeURIComponent(url.username);
  } catch {
    secretKey = url.username;
  }
  const params = url.searchParams;
  const host = url.hostname;
  const port = url.port;
  const endpoint = host ? (port ? `${host}:${port}` : host) : '';

  const addressRaw = firstParam(params, 'address', 'ip') ?? '';
  const address = addressRaw
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean);

  const allowedRaw = firstParam(params, 'allowedips', 'allowed_ips');
  const allowedIPs = allowedRaw
    ? allowedRaw
        .split(',')
        .map((s) => s.trim())
        .filter(Boolean)
    : ['0.0.0.0/0', '::/0'];

  const peer: Raw = {
    publicKey: firstParam(params, 'publickey', 'publicKey', 'public_key', 'peerPublicKey') ?? '',
    endpoint,
    allowedIPs,
  };
  const psk = firstParam(params, 'presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
  if (psk) peer.preSharedKey = psk;
  const keepAliveRaw = firstParam(
    params,
    'keepalive',
    'persistentkeepalive',
    'persistent_keepalive',
  );
  if (keepAliveRaw !== null) {
    const k = Number(keepAliveRaw);
    if (Number.isFinite(k)) peer.keepAlive = k;
  }

  const settings: Raw = { secretKey, address, peers: [peer] };
  const mtuRaw = firstParam(params, 'mtu');
  if (mtuRaw !== null) {
    const m = Number(mtuRaw);
    if (Number.isFinite(m)) settings.mtu = m;
  }
  const reservedRaw = firstParam(params, 'reserved');
  if (reservedRaw) {
    const reserved = reservedRaw
      .split(',')
      .map((s) => Number(s.trim()))
      .filter((n) => Number.isFinite(n));
    if (reserved.length > 0) settings.reserved = reserved;
  }

  return {
    protocol: 'wireguard',
    tag: decodeRemark(url),
    settings,
  };
}

// Dispatcher — first non-null parser wins. Returns null when no parser
// recognizes the link's protocol scheme.
export function parseOutboundLink(link: string): Raw | null {
  const trimmed = link.trim();
  if (!trimmed) return null;
  return (
    parseVmessLink(trimmed) ??
    parseVlessLink(trimmed) ??
    parseTrojanLink(trimmed) ??
    parseShadowsocksLink(trimmed) ??
    parseHysteria2Link(trimmed) ??
    parseWireguardLink(trimmed)
  );
}
