/**
 * Раскладка узлов карты сети и синтетические WAN→JH рёбра по данным серверов (как в оптимизаторе).
 */

import type { GreTunnel } from "@/lib/data"
import type { Server, ServerType } from "@/lib/data"
import {
  greOuterComparableIps,
  normalizeGreEndpointAddr,
  wanUplinkIpMatchesGreOuter,
} from "@/lib/gre-endpoint-resolve"

/** Визуализация метрик на GRE (нет отдельного API замеров для карты). */
export interface TunnelProbe {
  pingMs: number | null
  dlMbps: number | null
  ulMbps: number | null
}

export function greTunnelProbe(t: GreTunnel): TunnelProbe {
  if (t.status === "down" || !t.enabled)
    return { pingMs: null, dlMbps: null, ulMbps: null }
  const h = t.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
  if (t.status === "degraded") {
    return {
      pingMs: 35 + (h % 80),
      dlMbps: 40 + (h % 120),
      ulMbps: 35 + (h % 100),
    }
  }
  return {
    pingMs: 6 + (h % 30),
    dlMbps: 180 + (h % 520),
    ulMbps: 120 + (h % 400),
  }
}

const W = 1060
const H = 580
const MARGIN = 72

/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
export const NETWORK_MAP_PIPELINE_Y = 300

type NodeRole = "HR" | "JH" | "EN"

function roleOfServer(s: Server): NodeRole | null {
  if (s.type === "home-router") return "HR"
  if (s.type === "jump-host") return "JH"
  if (s.type === "exit-node") return "EN"
  return null
}

function roleLayer(role: NodeRole): number {
  switch (role) {
    case "HR": return 1
    case "JH": return 2
    case "EN": return 3
  }
}

function layerOfServer(s: Server): number | null {
  const r = roleOfServer(s)
  return r ? roleLayer(r) : null
}

/**
 * Увеличивать при изменении алгоритма раскладки спутников/узлов.
 * Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
 */
export const NETWORK_MAP_LAYOUT_REVISION = 6

export interface WanJhEdge {
  homeId: string
  wanIdx: number
  jhId: string
  /** Сумма `latency` домашнего узла и JH из каталога (как в /servers); null если нет обоих замеров. */
  pingMs: number | null
  dlMbps: number
  active: boolean
}

export interface NetworkMapSrvRes {
  cpu: number
  cpuHistory: number[]
  ramUsed: number
  ramTotal: number
  hddUsed: number
  hddTotal: number
  uptimeSeconds: number
  temp?: number
  /** CPU/RAM/uptime сняты с последнего снапшота опроса; HDD в API нет — по-прежнему условная шкала. */
  fromPoll?: boolean
}

/** Uptime из поля resource «1w2d3h4m5s» (как на бэкенде). */
export function parseRouterosUptimeToSeconds(raw: string | null | undefined): number {
  if (!raw) return 0
  let total = 0
  for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
    const n = Number.parseInt(m[1], 10)
    if (!Number.isFinite(n)) continue
    switch (m[2]) {
      case "w":
        total += n * 604800
        break
      case "d":
        total += n * 86400
        break
      case "h":
        total += n * 3600
        break
      case "m":
        total += n * 60
        break
      case "s":
        total += n
        break
    }
  }
  return total
}

/** Равномерно по отрезку [xMin, xMax] — несколько узлов в одной колонке. */
function spreadInSegment(count: number, index: number, xMin: number, xMax: number): number {
  if (count <= 0) return (xMin + xMax) / 2
  if (count === 1) return (xMin + xMax) / 2
  return xMin + (index / (count - 1)) * (xMax - xMin)
}

function clamp(n: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, n))
}

function resolveLaneCollisions(
  ids: string[],
  nodePos: Record<string, { x: number; y: number }>,
  minY: number,
  maxY: number,
  minGap: number,
): void {
  if (ids.length <= 1) {
    if (ids.length === 1) {
      const id = ids[0]
      const p = nodePos[id]
      if (p) p.y = clamp(p.y, minY, maxY)
    }
    return
  }
  const sorted = ids
    .map((id) => ({ id, y: nodePos[id]?.y ?? (minY + maxY) / 2 }))
    .sort((a, b) => a.y - b.y)
  const n = sorted.length
  const maxSpan = maxY - minY
  const gap = Math.min(minGap, maxSpan / (n - 1))
  const center = sorted.reduce((acc, s) => acc + s.y, 0) / n
  const span = gap * (n - 1)
  const start = clamp(center - span / 2, minY, maxY - span)
  for (let i = 0; i < n; i++) {
    const p = nodePos[sorted[i].id]
    if (!p) continue
    p.y = start + i * gap
  }
}

/** Детерминированные координаты узлов и «спутников» WAN под home-router.
 *
 * Важно: раскладка строго иерархическая по ролям:
 *  - HR (home-router) слева,
 *  - JH (jump-host) по центру,
 *  - EN (exit-node) справа.
 *
 * Это не force-layout: координаты полностью детерминированы по составу servers.
 */
function average(nums: number[]): number | null {
  if (nums.length === 0) return null
  return nums.reduce((acc, n) => acc + n, 0) / nums.length
}

function alignLayerCenterY(
  ids: string[],
  nodePos: Record<string, { x: number; y: number }>,
  targetCenterY: number,
  minY: number,
  maxY: number,
): void {
  if (ids.length === 0) return
  const ys = ids
    .map((id) => nodePos[id]?.y)
    .filter((v): v is number => typeof v === "number")
  const currentCenter = average(ys)
  if (currentCenter == null) return
  const delta = targetCenterY - currentCenter
  for (const id of ids) {
    const p = nodePos[id]
    if (!p) continue
    p.y = clamp(p.y + delta, minY, maxY)
  }
}

type HierarchyLinks = {
  hrToJh: Map<string, Set<string>>
  jhToEn: Map<string, Set<string>>
  hrToEn: Map<string, Set<string>>
}

function buildHierarchyLinks(
  servers: Server[],
  tunnels: GreTunnel[],
  resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): HierarchyLinks {
  const hrToJh = new Map<string, Set<string>>()
  const jhToEn = new Map<string, Set<string>>()
  const hrToEn = new Map<string, Set<string>>()
  const byId = new Map(servers.map((s) => [s.id, s] as const))

  function add(map: Map<string, Set<string>>, from: string, to: string) {
    const set = map.get(from) ?? new Set<string>()
    set.add(to)
    map.set(from, set)
  }

  for (const t of tunnels) {
    const a = byId.get(String(t.serverId))
    const b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
    if (!a || !b || a.id === b.id) continue

    const la = layerOfServer(a)
    const lb = layerOfServer(b)
    if (la == null || lb == null || la === lb) continue

    const from = la < lb ? a : b
    const to = la < lb ? b : a
    const fr = roleOfServer(from)
    const tr = roleOfServer(to)
    if (!fr || !tr) continue

    if (fr === "HR" && tr === "JH") add(hrToJh, from.id, to.id)
    else if (fr === "JH" && tr === "EN") add(jhToEn, from.id, to.id)
    else if (fr === "HR" && tr === "EN") add(hrToEn, from.id, to.id)
  }

  return { hrToJh, jhToEn, hrToEn }
}

export function computeNetworkMapLayout(
  servers: Server[],
  tunnels: GreTunnel[] = [],
  resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): {
  nodePos: Record<string, { x: number; y: number }>
  wanSatPos: Record<string, { x: number; y: number }[]>
} {
  const nodePos: Record<string, { x: number; y: number }> = {}
  const wanSatPos: Record<string, { x: number; y: number }[]> = {}

  const span = W - 2 * MARGIN
  const laneGap = Math.min(44, span * 0.04)
  const laneW = (span - 2 * laneGap) / 3

  // Жёсткое позиционирование по иерархическим слоям:
  //  - HR: левая полоса (0–20%),
  //  - JH: центральная (40–60%),
  //  - EN: правая (80–100%).
  const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
  const laneByType = new Map<ServerType, Server[]>()
  const minNodeY = MARGIN + 70
  const maxNodeY = H - 72
  const laneMinGap = 110
  const typeYOffset: Record<ServerType, number> = {
    "home-router": -14,
    "jump-host": 0,
    "exit-node": 14,
  }
  const siteWeight = (s: Server): number => {
    const typeBoost = s.type === "jump-host" ? 3 : s.type === "home-router" ? 2 : 1
    const statusBoost = s.status === "online" ? 2 : s.status === "degraded" ? 1 : 0
    return typeBoost + statusBoost
  }
  const siteNames = [
    ...new Set(
      servers
        .map((s) => s.site?.trim())
        .filter((v): v is string => Boolean(v)),
    ),
  ]
  siteNames.sort((a, b) => {
    const wa = servers.filter((s) => s.site === a).reduce((acc, s) => acc + siteWeight(s), 0)
    const wb = servers.filter((s) => s.site === b).reduce((acc, s) => acc + siteWeight(s), 0)
    if (wb !== wa) return wb - wa
    return a.localeCompare(b)
  })
  const siteY = new Map<string, number>()
  siteNames.forEach((site, i) => {
    siteY.set(site, spreadInSegment(siteNames.length, i, minNodeY, maxNodeY))
  })
  const fallbackY = (minNodeY + maxNodeY) / 2
  let xLane = MARGIN
  for (const type of laneOrder) {
    const group = servers
      .filter((s) => s.type === type)
      .slice()
      .sort((a, b) => `${a.site}|${a.name}|${a.id}`.localeCompare(`${b.site}|${b.name}|${b.id}`))
    laneByType.set(type, group)

    const xMin = xLane
    const xMax = xLane + laneW
    const laneCenterX = (xMin + xMax) / 2

    group.forEach((s) => {
      const baseline = siteY.get(s.site) ?? fallbackY
      const siblings = group.filter((g) => g.site === s.site)
      const sibIdx = siblings.findIndex((g) => g.id === s.id)
      const spread = siblings.length <= 1 ? 0 : (sibIdx - (siblings.length - 1) / 2) * 34
      nodePos[s.id] = {
        x: laneCenterX,
        y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
      }
    })

    xLane += laneW + laneGap
  }

  const homes = laneByType.get("home-router") ?? []
  const jhs = laneByType.get("jump-host") ?? []
  const exits = laneByType.get("exit-node") ?? []

  const links = buildHierarchyLinks(servers, tunnels, resolvedIpv4ByHost)
  const hrParentsByJh = new Map<string, string[]>()
  for (const [hrId, jhSet] of links.hrToJh.entries()) {
    for (const jhId of jhSet) {
      const arr = hrParentsByJh.get(jhId) ?? []
      arr.push(hrId)
      hrParentsByJh.set(jhId, arr)
    }
  }
  // JH тянем по Y к HR-родителям, чтобы хабы не "плавали" от site-сортировки.
  for (const jh of jhs) {
    const parentIds = hrParentsByJh.get(jh.id) ?? []
    const parentYs = parentIds
      .map((id) => nodePos[id]?.y)
      .filter((v): v is number => typeof v === "number")
    const target = average(parentYs)
    if (target != null && nodePos[jh.id]) {
      nodePos[jh.id]!.y = clamp(target, minNodeY, maxNodeY)
    }
  }
  resolveLaneCollisions(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
  resolveLaneCollisions(jhs.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)

  const jhParentsByEn = new Map<string, string[]>()
  for (const [jhId, enSet] of links.jhToEn.entries()) {
    for (const enId of enSet) {
      const arr = jhParentsByEn.get(enId) ?? []
      arr.push(jhId)
      jhParentsByEn.set(enId, arr)
    }
  }
  const hrParentsByEn = new Map<string, string[]>()
  for (const [hrId, enSet] of links.hrToEn.entries()) {
    for (const enId of enSet) {
      const arr = hrParentsByEn.get(enId) ?? []
      arr.push(hrId)
      hrParentsByEn.set(enId, arr)
    }
  }
  // EN вешаем на JH-родителей (или HR fallback), чтобы получать читаемую правую "leaf"-ветку.
  for (const en of exits) {
    const jhParentIds = jhParentsByEn.get(en.id) ?? []
    const jhParentYs = jhParentIds
      .map((id) => nodePos[id]?.y)
      .filter((v): v is number => typeof v === "number")
    const jhTarget = average(jhParentYs)
    if (jhTarget != null && nodePos[en.id]) {
      nodePos[en.id]!.y = clamp(jhTarget, minNodeY, maxNodeY)
      continue
    }
    const hrParentIds = hrParentsByEn.get(en.id) ?? []
    const hrParentYs = hrParentIds
      .map((id) => nodePos[id]?.y)
      .filter((v): v is number => typeof v === "number")
    const hrTarget = average(hrParentYs)
    if (hrTarget != null && nodePos[en.id]) {
      nodePos[en.id]!.y = clamp(hrTarget, minNodeY, maxNodeY)
    }
  }
  resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)

  // EN-слой выравниваем относительно центра HR-слоя:
  // карта читается как "ingress слева -> leaf справа" на одной оси.
  const hrCenterY = average(
    homes
      .map((s) => nodePos[s.id]?.y)
      .filter((v): v is number => typeof v === "number"),
  )
  if (hrCenterY != null) {
    alignLayerCenterY(exits.map((s) => s.id), nodePos, hrCenterY, minNodeY, maxNodeY)
    resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
  }

  const hr = homes
  /** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
  const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2

  for (const r of hr) {
    const wans = r.wanUplinks?.length ? r.wanUplinks : []
    const base = nodePos[r.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
    const n = wans.length
    const satX = (base.x + jhColumnCenterX) / 2
    const rowY = base.y
    const maxV = H - 2 * MARGIN - 96
    // Разносим WAN-аплинки по высоте заметнее для читаемости подписей/бейджей.
    const preferredGap = 140
    const vGap =
      n <= 1 ? 0 : Math.min(preferredGap, maxV / Math.max(1, n - 1))
    const positions: { x: number; y: number }[] = []
    for (let i = 0; i < n; i++) {
      const rawY = n === 1 ? rowY : rowY + (i - (n - 1) / 2) * vGap
      const y = Math.max(MARGIN + 48, Math.min(H - 72, rawY))
      positions.push({ x: satX, y })
    }
    wanSatPos[r.id] = positions
  }

  return { nodePos, wanSatPos }
}

/**
 * Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
 * Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
 */
function legPing(home: Server, jh: Server): number | null {
  /** Для линий на карте достаточно «живого» статуса; `enabled` не скрывает топологию. */
  const hOk = home.status !== "offline"
  const jOk = jh.status !== "offline"
  if (!hOk || !jOk) return null
  if (home.latency == null || jh.latency == null) return null
  return Math.min(995, Math.round(home.latency + jh.latency))
}

/** Рёбра WAN→JumpHost для отрисовки (`latency` с бекенда — те же объекты, что и в каталоге серверов). */
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
  const homes = servers.filter((s) => s.type === "home-router")
  const jhs = servers.filter((s) => s.type === "jump-host" && s.status !== "offline")
  if (homes.length === 0 || jhs.length === 0) return []

  const edges: WanJhEdge[] = []
  for (const home of homes) {
    const wans = home.wanUplinks?.length ? home.wanUplinks : []
    if (wans.length === 0) continue
    for (let wanIdx = 0; wanIdx < wans.length; wanIdx++) {
      const wan = wans[wanIdx]
      for (const jh of jhs) {
        edges.push({
          homeId: home.id,
          wanIdx,
          jhId: jh.id,
          pingMs: legPing(home, jh),
          dlMbps: Math.max(1, wan.maxDl),
          active: false,
        })
      }
    }
  }

  const key = (e: WanJhEdge) => `${e.homeId}::${e.wanIdx}`
  const score = (p: number | null) => (p == null ? Number.POSITIVE_INFINITY : p)
  const best = new Map<string, WanJhEdge>()
  for (const e of edges) {
    const k = key(e)
    const prev = best.get(k)
    if (!prev || score(e.pingMs) < score(prev.pingMs)) best.set(k, e)
  }
  for (const e of edges) {
    const winner = best.get(key(e))
    e.active =
      winner != null &&
      winner.pingMs != null &&
      e.jhId === winner.jhId &&
      e.pingMs === winner.pingMs
  }

  return edges
}

/** Ресурсы для карточки узла на карте: при наличии снапшота опроса — как в разделе «Серверы», иначе демо по id. */
export function buildServerResourceMap(servers: Server[]): Record<string, NetworkMapSrvRes> {
  return Object.fromEntries(
    servers.map((s) => {
      const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
      const ramTotalDemo = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024
      const hddTotalDemo = s.type === "home-router" ? 2048 : 16384
      const ramPctDemo = 12 + (h % 72)
      const hddPctDemo = 8 + (h % 75)

      const hasPollRam =
        s.cpuLoad != null &&
        typeof s.totalMemory === "number" &&
        s.totalMemory > 0 &&
        typeof s.freeMemory === "number"

      if (hasPollRam) {
        const cpu = Math.max(0, Math.min(100, Math.round(s.cpuLoad!)))
        const ramTotalMb = Math.max(1, Math.round(s.totalMemory! / (1024 * 1024)))
        const usedBytes = Math.max(0, s.totalMemory! - s.freeMemory!)
        const ramUsedMb = Math.min(ramTotalMb, Math.max(0, Math.round(usedBytes / (1024 * 1024))))
        let uptimeSeconds = s.uptime ? parseRouterosUptimeToSeconds(s.uptime) : 0
        if (uptimeSeconds <= 0 && s.uptime) {
          uptimeSeconds =
            (1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
        }
        if (uptimeSeconds <= 0) {
          uptimeSeconds =
            (1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
        }
        const hddTotal = hddTotalDemo
        const hddUsed = Math.round((hddTotal * hddPctDemo) / 100)
        return [
          s.id,
          {
            cpu,
            cpuHistory: Array.from({ length: 32 }, (_, i) =>
              Math.max(0, Math.min(100, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 6))),
            ),
            ramUsed: ramUsedMb,
            ramTotal: ramTotalMb,
            hddUsed,
            hddTotal,
            uptimeSeconds,
            fromPoll: true,
          } satisfies NetworkMapSrvRes,
        ]
      }

      const cpu = 4 + (h % 68)
      const ramTotal = ramTotalDemo
      const hddTotal = hddTotalDemo
      return [
        s.id,
        {
          cpu,
          cpuHistory: Array.from({ length: 32 }, (_, i) =>
            Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15))),
          ),
          ramUsed: Math.round((ramTotal * ramPctDemo) / 100),
          ramTotal,
          hddUsed: Math.round((hddTotal * hddPctDemo) / 100),
          hddTotal,
          uptimeSeconds:
            (1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
          temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
          fromPoll: false,
        } satisfies NetworkMapSrvRes,
      ]
    }),
  )
}

// ── GRE: сопоставление remote-address с узлами (WAN ≠ API host в БД) ─────────

/** Нормализация IP/endpoint из RouterOS (CIDR, zone id). */
export function normalizeGreEndpoint(addr: string): string {
  return normalizeGreEndpointAddr(addr)
}

function hostWithoutPort(host: string): string {
  const h = host.trim()
  if (h.startsWith("[")) {
    const end = h.indexOf("]")
    return end > 0 ? h.slice(1, end) : h
  }
  const lastColon = h.lastIndexOf(":")
  if (lastColon > 0 && /^\d{1,5}$/.test(h.slice(lastColon + 1)))
    return h.slice(0, lastColon)
  return h
}

function normalizeIpNoMask(raw: string | null | undefined): string {
  if (!raw) return ""
  return normalizeGreEndpoint(raw.split("/")[0] ?? "")
}

function canonicalIpPairKey(aRaw: string, bRaw: string): string | null {
  const a = normalizeGreEndpoint(aRaw)
  const b = normalizeGreEndpoint(bRaw)
  if (!a || !b || a === "0.0.0.0" || b === "0.0.0.0") return null
  const p1 = a <= b ? a : b
  const p2 = a <= b ? b : a
  return `${p1}|${p2}`
}

function canonicalOuterPairKey(
  t: GreTunnel,
  resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): string | null {
  const la = greOuterComparableIps(t.localAddress, resolvedIpv4ByHost)
  const ra = greOuterComparableIps(t.remoteAddress, resolvedIpv4ByHost)
  const left = [...new Set(la.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
  const right = [...new Set(ra.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
  if (left.length === 0 || right.length === 0) return null
  let best: string | null = null
  for (const l of left) {
    for (const r of right) {
      const p1 = l <= r ? l : r
      const p2 = l <= r ? r : l
      const k = `${p1}|${p2}`
      if (best == null || k < best) best = k
    }
  }
  return best
}

/**
 * Найти сервер в каталоге по GRE remote-address: совпадение с management host или WAN IP.
 * Для FQDN в remote-address — передайте карту DNS из `/api/network/resolve-hosts`.
 *
 * Важно: при успешном DNS исходный FQDN всё равно участвует в поиске первым — иначе совпадение
 * только по резолвнутому IP может выбрать «чужой» узел с тем же WAN, а speed-пробы перестанут
 * сходиться с парой концов туннеля.
 */
export function findServerByGreRemote(
  servers: Server[],
  remoteAddress: string,
  resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): Server | undefined {
  const fallback = normalizeGreEndpoint(remoteAddress)
  if (!fallback || fallback === "0.0.0.0") return undefined

  const comparable = greOuterComparableIps(remoteAddress, resolvedIpv4ByHost)
  const tryOrder: string[] = []
  const seen = new Set<string>()
  function pushCandidate(raw: string) {
    const n = normalizeGreEndpoint(raw)
    if (!n || n === "0.0.0.0" || seen.has(n)) return
    seen.add(n)
    tryOrder.push(n)
  }

  pushCandidate(fallback)
  for (const ip of comparable) pushCandidate(ip)

  for (const c of tryOrder) {
    for (const s of servers) {
      if (normalizeGreEndpoint(hostWithoutPort(s.host)) === c) return s
    }
  }
  for (const c of tryOrder) {
    for (const s of servers) {
      for (const w of s.wanUplinks ?? []) {
        if (normalizeGreEndpoint(w.ip) === c) return s
      }
    }
  }
  return undefined
}

/** Индекс WAN-аплинка Home Router по внешнему IP (GRE outer / remote-address). */
export function wanIndexMatchingOuterIp(
  server: Server,
  outerIp: string,
  resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): number | null {
  if (server.type !== "home-router") return null
  if (!normalizeGreEndpoint(outerIp)) return null
  const wans = server.wanUplinks ?? []
  for (let i = 0; i < wans.length; i++) {
    if (wanUplinkIpMatchesGreOuter(wans[i].ip, outerIp, resolvedIpv4ByHost)) return i
  }
  return null
}

/**
 * Индекс WAN-спутника на стороне узла, где поднят GRE (совпадает с якорем «source» на карте).
 * Для сопоставления ребра WAN→JH с GRE-бейджем на том же аплинке.
 */
export function greSourceWanIndexOnMap(
  home: Server,
  tunnel: GreTunnel,
  resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): number | null {
  if (home.type !== "home-router") return null
  const wans = home.wanUplinks ?? []
  if (wans.length === 0) return null
  const la = tunnel.localAddress?.trim() ?? ""
  let wanIdx: number | null = null
  if (la === "0.0.0.0" || la === "") wanIdx = 0
  else wanIdx = wanIndexMatchingOuterIp(home, tunnel.localAddress, resolvedIpv4ByHost)
  if (wanIdx == null) wanIdx = 0
  return wanIdx
}

function greEndpointAnchor(
  server: Server,
  tunnel: GreTunnel,
  role: "source" | "peer",
  nodePos: Record<string, { x: number; y: number }>,
  wanSatPos: Record<string, { x: number; y: number }[]>,
  resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): { x: number; y: number } {
  const center = (): { x: number; y: number } =>
    nodePos[server.id] ?? { x: W / 2, y: H / 2 }

  if (server.type !== "home-router") return center()

  const wans = server.wanUplinks ?? []
  let wanIdx: number | null = null
  if (role === "source") {
    const la = tunnel.localAddress?.trim() ?? ""
    if (la === "0.0.0.0" || la === "") wanIdx = wans.length > 0 ? 0 : null
    else wanIdx = wanIndexMatchingOuterIp(server, tunnel.localAddress, resolvedIpv4ByHost)
    if (wanIdx == null && wans.length > 0) wanIdx = 0
  } else {
    wanIdx = wanIndexMatchingOuterIp(server, tunnel.remoteAddress, resolvedIpv4ByHost)
  }

  if (wanIdx != null && wanSatPos[server.id]?.[wanIdx])
    return wanSatPos[server.id][wanIdx]!
  return center()
}

/** GRE на карте только между узлами каталога; у HR конец туннеля — у соответствующего WAN-спутника. */
export interface GreMapEdge {
  tunnel: GreTunnel
  from: { x: number; y: number }
  to: { x: number; y: number }
  fromServer: Server
  toServer: Server
}

export function buildGreMapEdges(
  tunnels: GreTunnel[],
  servers: Server[],
  nodePos: Record<string, { x: number; y: number }>,
  wanSatPos: Record<string, { x: number; y: number }[]>,
  resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): GreMapEdge[] {
  const out: GreMapEdge[] = []
  const seenByLink = new Map<string, number>()

  const statusRank: Record<GreTunnel["status"], number> = {
    up: 3,
    degraded: 2,
    down: 1,
  }

  function tunnelPriority(t: GreTunnel): number {
    return (t.enabled ? 100 : 0) + (statusRank[t.status] ?? 0)
  }

  function canonicalLinkKey(t: GreTunnel, a: Server, b: Server): string {
    const s1 = a.id <= b.id ? a.id : b.id
    const s2 = a.id <= b.id ? b.id : a.id
    // 1) Предпочитаем inner-пару: у directional A→B/B→A она одинакова по /30.
    const inner = canonicalIpPairKey(
      normalizeIpNoMask(t.localInnerIp),
      normalizeIpNoMask(t.remoteInnerIp),
    )
    if (inner) return `${s1}|${s2}|inner:${inner}`

    // 2) Иначе outer-пара с учетом DNS/FQDN→IPv4 сопоставления.
    const outer = canonicalOuterPairKey(t, resolvedIpv4ByHost)
    if (outer) return `${s1}|${s2}|outer:${outer}`

    // 3) Крайний случай: чтобы не схлопнуть потенциально разные линki.
    return `${s1}|${s2}|id:${t.id}`
  }

  for (const t of tunnels) {
    let a = servers.find((s) => s.id === t.serverId)
    let b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
    if (!a || !b) continue
    if (a.id === b.id) continue

    const la = layerOfServer(a)
    const lb = layerOfServer(b)
    if (la == null || lb == null) continue
    // EN→EN, JH→JH и т.п. на карте не показываем — только межуровневые рёбра.
    if (la === lb) continue

    // Строгий поток слева направо: HR → JH → EN.
    // Если направление туннеля против иерархии — переворачиваем визуальное ребро.
    let fromServer = a
    let toServer = b
    if (la > lb) {
      fromServer = b
      toServer = a
    }

    const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
    const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
    const edge: GreMapEdge = { tunnel: t, from, to, fromServer, toServer }
    const key = canonicalLinkKey(t, fromServer, toServer)
    const prevIdx = seenByLink.get(key)
    if (prevIdx == null) {
      seenByLink.set(key, out.length)
      out.push(edge)
      continue
    }
    const prev = out[prevIdx]
    if (tunnelPriority(t) > tunnelPriority(prev.tunnel)) {
      out[prevIdx] = edge
    }
  }
  return out
}
