import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
import { db } from "../db/index.js"
import { servers, userInterfaceBindings } from "../db/schema.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import {
  isNamedInternetService,
  lookupBrand,
  mapServiceNodeId,
} from "./traffic-flow-brands.js"
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { loadFlowTopology, resolveClient, resolveEn } from "./traffic-flow-topology.js"

export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
export const MAP_SERVICE_NODE_CAP = 20
const HOPS_CACHE_TTL_MS = 2000

export interface FlowMapHopsQuery {
  minutes: number
  serverId?: number
  userId?: string
  iface?: string
  dedup?: boolean
  excludeMesh?: boolean
  excludeOverlay?: boolean
  /** Переопределение порога (тесты). Иначе из настроек NetFlow. */
  minSharePct?: number
}

interface HopAcc {
  fromId: string
  fromLabel: string
  toId: string
  toLabel: string
  kind: FlowMapHop["kind"]
  iface?: string
  bytes: number
  bytesFwd: number
  bytesRev: number
}

interface FromAcc {
  bytes: number
  clients: Map<string, string>
}

interface DstAcc {
  bytes: number
  proto: number
  dstPort: number
  srcPort: number
  fromBytes: Map<string, FromAcc>
}

function bumpFrom(acc: DstAcc, exporterId: string, bytes: number, client: { userId: string; name: string } | null): void {
  const prev = acc.fromBytes.get(exporterId)
  if (prev) {
    prev.bytes += bytes
    if (client) prev.clients.set(client.userId, client.name)
    return
  }
  const clients = new Map<string, string>()
  if (client) clients.set(client.userId, client.name)
  acc.fromBytes.set(exporterId, { bytes, clients })
}

let hopsCache: { key: string; at: number; dto: FlowMapHopsDto } | null = null

export function resetFlowMapHopsCacheForTests(): void {
  hopsCache = null
}

export function clampMapServiceMinSharePct(n: unknown): number {
  const v = typeof n === "number" ? n : Number(n)
  if (!Number.isFinite(v)) return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
  return Math.min(100, Math.max(0, v))
}

function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
  return JSON.stringify({
    minutes: q.minutes,
    serverId: q.serverId ?? null,
    userId: q.userId ?? null,
    iface: q.iface ?? null,
    dedup: q.dedup !== false,
    excludeMesh: q.excludeMesh !== false,
    excludeOverlay: q.excludeOverlay !== false,
    minSharePct,
  })
}

function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
  if (!userId) return null
  const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
  const allow = new Map<number, Set<string>>()
  for (const b of binds) {
    const set = allow.get(b.serverId) ?? new Set<string>()
    set.add(b.interfaceName)
    allow.set(b.serverId, set)
  }
  return allow
}

function ifaceUsable(name: string): boolean {
  return Boolean(name) && name !== "—"
}

function bump(acc: Map<string, HopAcc>, key: string, seed: Omit<HopAcc, "bytes" | "bytesFwd" | "bytesRev">, bytes: number, dir: "fwd" | "rev" | "both"): void {
  const prev = acc.get(key)
  const addFwd = dir === "fwd" || dir === "both" ? bytes : 0
  const addRev = dir === "rev" || dir === "both" ? bytes : 0
  if (prev) {
    prev.bytes += bytes
    prev.bytesFwd += addFwd
    prev.bytesRev += addRev
    if (seed.iface && !prev.iface) prev.iface = seed.iface
    return
  }
  acc.set(key, {
    ...seed,
    bytes,
    bytesFwd: addFwd,
    bytesRev: addRev,
  })
}

function toHop(a: HopAcc, windowSec: number): FlowMapHop {
  return {
    fromId: a.fromId,
    fromLabel: a.fromLabel,
    toId: a.toId,
    toLabel: a.toLabel,
    kind: a.kind,
    ...(a.iface ? { iface: a.iface } : {}),
    bytes: a.bytes,
    bps: (a.bytes * 8) / windowSec,
    bpsFwd: (a.bytesFwd * 8) / windowSec,
    bpsRev: (a.bytesRev * 8) / windowSec,
  }
}

/** Имя бренда без каталога EvoBGP — только ASN/CIDR кэш + proto. */
function classifyMapDstLite(
  dst: string,
  proto: number,
  dstPort: number,
  srcPort: number,
  ripe: FlowIpMeta | null,
): { service: string; category: string } | null {
  if (proto === 47 || proto === 50) return null
  const app = applicationName(proto, dstPort, srcPort)
  if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
  if (/youtube/i.test(ripe?.holder ?? "")) {
    return { service: "YouTube", category: "Видео / стриминг" }
  }
  const brand = lookupBrand(dst, ripe?.asn ?? 0)
  if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
  return brand
}

function resolveMinSharePct(q: FlowMapHopsQuery): number {
  if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
  try {
    const row = getTrafficFlowSettingsRow() as { mapServiceMinSharePct?: number }
    return clampMapServiceMinSharePct(row.mapServiceMinSharePct ?? DEFAULT_MAP_SERVICE_MIN_SHARE_PCT)
  } catch {
    return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
  }
}

function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): FlowMapHopsDto {
  const windowSec = Math.max(60, q.minutes * 60)
  const raw = listFlowRowsForWindow(q.minutes)
  const allow = q.userId ? userIfaceAllow(q.userId) : null
  const serverRows = db.select().from(servers).all()
  const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
  const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
  const wantDedup = q.dedup !== false && !ifaceFilter
  const excludeMesh = q.excludeMesh !== false
  const excludeOverlay = q.excludeOverlay !== false
  const topo = loadFlowTopology()

  const matched = []
  for (const r of raw) {
    const resolved = resolveIfaceName(r.serverId, r.inIface)
    const outResolved = resolveIfaceName(r.serverId, r.outIface)
    if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
    const plane = classifyFlowPlane({
      src: r.src,
      dst: r.dst,
      proto: r.proto,
      srcPort: r.srcPort,
      dstPort: r.dstPort,
      inIface: resolved.name,
      outIface: outResolved.name,
    }, topo.plane)
    if (!shouldKeepPlane(plane, { excludeMesh, excludeOverlay })) continue
    matched.push(r)
  }

  const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
  const hops = new Map<string, HopAcc>()
  const dstAcc = new Map<string, DstAcc>()
  const jhToEn = new Map<number, number>()
  const enIds = new Set(topo.enNodes.map((n) => n.id))
  let totalBytes = 0

  for (const r of working) {
    const inRes = resolveIfaceName(r.serverId, r.inIface)
    const outRes = resolveIfaceName(r.serverId, r.outIface)
    const inName = inRes.name
    const outName = outRes.name
    const fromId = String(r.serverId)
    const fromLabel = nameById.get(r.serverId) ?? fromId
    const wanSet = topo.wanIfaces.get(r.serverId)

    const inOk = ifaceUsable(inName)
    const outOk = ifaceUsable(outName)
    const sameIface = inOk && outOk && inName.toLowerCase() === outName.toLowerCase()
    if (sameIface) {
      bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, {
        fromId,
        fromLabel,
        toId: "",
        toLabel: "",
        kind: "iface",
        iface: inName,
      }, r.bytes, "fwd")
    } else {
      if (inOk) {
        bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, {
          fromId,
          fromLabel,
          toId: "",
          toLabel: "",
          kind: "iface",
          iface: inName,
        }, r.bytes, "rev")
      }
      if (outOk) {
        bump(hops, `iface|${fromId}|${outName.toLowerCase()}`, {
          fromId,
          fromLabel,
          toId: "",
          toLabel: "",
          kind: "iface",
          iface: outName,
        }, r.bytes, "fwd")
      }
    }

    const enOut = ifaceUsable(outName) ? resolveEn(topo, r.nextHop, outName) : null
    const enIn = ifaceUsable(inName) ? resolveEn(topo, "", inName) : null
    const en = (enOut && enOut.id !== r.serverId ? enOut : null)
      ?? (enIn && enIn.id !== r.serverId ? enIn : null)
    if (en) {
      jhToEn.set(r.serverId, en.id)
      const toId = String(en.id)
      const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev"
      const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined)
      bump(hops, `gre|${fromId}|${toId}`, {
        fromId,
        fromLabel,
        toId,
        toLabel: en.name,
        kind: "gre",
        iface: greIface,
      }, r.bytes, dir)
    }

    if (wanSet?.size) {
      if (ifaceUsable(inName) && wanSet.has(inName)) {
        bump(hops, `wan|${fromId}|${inName.toLowerCase()}`, {
          fromId,
          fromLabel,
          toId: "",
          toLabel: "",
          kind: "wan",
          iface: inName,
        }, r.bytes, "rev")
      }
      if (ifaceUsable(outName) && wanSet.has(outName) && outName.toLowerCase() !== inName.toLowerCase()) {
        bump(hops, `wan|${fromId}|${outName.toLowerCase()}`, {
          fromId,
          fromLabel,
          toId: "",
          toLabel: "",
          kind: "wan",
          iface: outName,
        }, r.bytes, "fwd")
      }
    }

    totalBytes += r.bytes
    const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
    const client = resolveClient(topo, r.serverId, inName)
    const prevDst = dstAcc.get(peer)
    if (prevDst) {
      prevDst.bytes += r.bytes
      bumpFrom(prevDst, String(r.serverId), r.bytes, client)
    } else {
      const acc: DstAcc = {
        bytes: r.bytes,
        proto: r.proto,
        dstPort: r.dstPort,
        srcPort: r.srcPort,
        fromBytes: new Map(),
      }
      bumpFrom(acc, String(r.serverId), r.bytes, client)
      dstAcc.set(peer, acc)
    }
  }

  const svcTotals = new Map<string, { label: string; category: string; bytes: number }>()
  const svcEdges = new Map<string, {
    fromId: string
    toId: string
    bytes: number
    bytesFwd: number
    bytesRev: number
    clients: Map<string, string>
  }>()

  function anchorEnId(exporterId: string): string | null {
    const n = Number(exporterId)
    if (enIds.has(n)) return exporterId
    const mapped = jhToEn.get(n)
    if (mapped != null) return String(mapped)
    return null
  }

  for (const [dst, acc] of dstAcc) {
    const ripe = lookupRipeCached(dst)
    const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
    if (!classified) continue
    const toId = mapServiceNodeId(classified.service)
    const prevSvc = svcTotals.get(toId)
    if (prevSvc) prevSvc.bytes += acc.bytes
    else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: acc.bytes })
    for (const [exporterId, from] of acc.fromBytes) {
      const fromId = anchorEnId(exporterId)
      if (!fromId) continue
      const edgeKey = `${fromId}|${toId}`
      const prevEdge = svcEdges.get(edgeKey)
      if (prevEdge) {
        prevEdge.bytes += from.bytes
        prevEdge.bytesFwd += from.bytes
        for (const [id, name] of from.clients) prevEdge.clients.set(id, name)
      } else {
        svcEdges.set(edgeKey, {
          fromId,
          toId,
          bytes: from.bytes,
          bytesFwd: from.bytes,
          bytesRev: 0,
          clients: new Map(from.clients),
        })
      }
    }
  }

  const minShare = minSharePct / 100
  let services: FlowMapService[] = [...svcTotals.entries()]
    .map(([id, s]) => ({
      id,
      label: s.label,
      category: s.category,
      bytes: s.bytes,
      bps: (s.bytes * 8) / windowSec,
      share: totalBytes > 0 ? s.bytes / totalBytes : 0,
    }))
    .sort((a, b) => b.bytes - a.bytes)
  if (minSharePct > 0) {
    services = services.filter((s) => s.share >= minShare)
  }
  services = services.slice(0, MAP_SERVICE_NODE_CAP)
  const keepSvc = new Set(services.map((s) => s.id))
  const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
    .filter((e) => keepSvc.has(e.toId))
    .map((e) => {
      const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
      const first = clients[0]
      return {
        fromId: e.fromId,
        toId: e.toId,
        bytes: e.bytes,
        bps: (e.bytes * 8) / windowSec,
        bpsFwd: (e.bytesFwd * 8) / windowSec,
        bpsRev: (e.bytesRev * 8) / windowSec,
        ...(first ? { clientId: first.id, clientName: first.name } : {}),
        ...(clients.length ? { clients } : {}),
      }
    })
    .sort((a, b) => b.bytes - a.bytes)

  const listener = getFlowListenerState()
  return {
    hops: [...hops.values()]
      .map((a) => toHop(a, windowSec))
      .sort((a, b) => a.bytes === b.bytes ? 0 : b.bytes - a.bytes),
    live: listener.bound,
    rangeMinutes: q.minutes,
    windowSec,
    totalBytes,
    services,
    serviceEdges,
    mapServiceMinSharePct: minSharePct,
    dedupApplied: wantDedup,
    excludeMeshApplied: excludeMesh,
    excludeOverlayApplied: excludeOverlay,
  }
}

/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
  const minSharePct = resolveMinSharePct(q)
  const key = hopsQueryKey(q, minSharePct)
  const now = Date.now()
  if (hopsCache && hopsCache.key === key && now - hopsCache.at < HOPS_CACHE_TTL_MS) {
    return hopsCache.dto
  }
  const dto = buildFlowMapHopsUncached(q, minSharePct)
  hopsCache = { key, at: now, dto }
  return dto
}
