import dns from "node:dns/promises"
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import type { RosIpRoute, RosPingResult } from "../types/server.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
import { abortAfterMs, mergeAbortSignals } from "../utils/abort-signals.js"

/**
 * Документация REST API RouterOS: если команда «бесконечна», сессия всё равно закрывается
 * через ~60 с; параметры команды **не** продлевают этот лимит.
 * @see https://help.mikrotik.com/docs/display/ROS/REST+API — раздел «Timeout»
 */
const ROS_REST_SESSION_MAX_MS = 58_000

/** Парсинг ввода пользователя (800ms, 1s, 00:00:01) → миллисекунды (10–3000). */
function parseTraceHopInputToMs(raw: unknown): number {
  const s = String(raw ?? "").trim().toLowerCase()
  if (!s) return 1000
  const msM = /^(\d+)ms$/.exec(s)
  if (msM) return Math.min(3000, Math.max(10, Number(msM[1])))
  const secM = /^(\d+(?:\.\d+)?)s$/.exec(s)
  if (secM) return Math.min(3000, Math.max(10, Number(secM[1]) * 1000))
  const hm = /^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(String(raw ?? "").trim())
  if (hm) {
    const h = Number(hm[1]), m = Number(hm[2]), sec = Number(hm[3])
    const frac = hm[4] ? Number(hm[4].padEnd(3, "0").slice(0, 3)) : 0
    const t = ((h * 60 + m) * 60 + sec) * 1000 + frac
    return Math.min(3000, Math.max(10, t))
  }
  return 1000
}

/** В REST JSON для /tool/traceroute нужен формат времени HH:MM:SS (не «1s»). */
function formatMsAsRosTracerouteTimeout(ms: number): string {
  const capped = Math.min(3000, Math.max(10, Math.round(ms)))
  const totalSeconds = Math.floor(capped / 1000)
  const milli = capped % 1000
  const ss = totalSeconds % 60
  const mmTotal = Math.floor(totalSeconds / 60)
  const mm = mmTotal % 60
  const hh = Math.floor(mmTotal / 60)
  const pad = (n: number) => String(n).padStart(2, "0")
  if (milli === 0) return `${pad(hh)}:${pad(mm)}:${pad(ss)}`
  return `${pad(hh)}:${pad(mm)}:${pad(ss)}.${String(milli).padStart(3, "0")}`
}

function traceHopTimeoutForApi(raw: unknown): string {
  return formatMsAsRosTracerouteTimeout(parseTraceHopInputToMs(raw))
}

function parseServerId(raw: string): number | null {
  const n = Number.parseInt(raw, 10)
  return Number.isFinite(n) ? n : null
}

function ipv4ToUint(ip: string): number | null {
  const p = ip.split(".").map((x) => Number.parseInt(x, 10))
  if (p.length !== 4 || p.some((x) => !Number.isFinite(x) || x < 0 || x > 255)) return null
  return (((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0)
}

function maskFromLen(len: number): number {
  if (len <= 0) return 0
  if (len >= 32) return 0xffffffff
  return (~((1 << (32 - len)) - 1)) >>> 0
}

function parseDstRoute(dst: string): { net: number; maskBits: number } | null {
  const t = dst.trim()
  if (!t) return null
  if (!t.includes("/")) {
    const ip = ipv4ToUint(t)
    return ip === null ? null : { net: ip, maskBits: 32 }
  }
  const [addr, mb] = t.split("/")
  const ip = ipv4ToUint(addr.trim())
  const maskBits = Number.parseInt((mb ?? "").trim(), 10)
  if (ip === null || !Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null
  const mask = maskFromLen(maskBits)
  return { net: ip & mask, maskBits }
}

function ipMatchesRoute(destIp: string, dstAddressField: string): boolean {
  const ip = ipv4ToUint(destIp.trim())
  if (ip === null) return false
  const cidr = parseDstRoute(dstAddressField)
  if (!cidr) return false
  const mask = maskFromLen(cidr.maskBits)
  return (ip & mask) === (cidr.net & mask)
}

function fmtPingRouterOs(results: RosPingResult[], host: string): string {
  const lines = [`PING ${host}`]
  for (const r of results) {
    if (!r.seq && !r.sent && r.status !== "timeout") continue
    if (r.status === "timeout") {
      lines.push(`  seq=${r.seq ?? "?"} timeout`)
    } else if (r.seq && r.time !== undefined) {
      lines.push(`  seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time}`)
    }
  }
  const sum = [...results].reverse().find((r) => r.sent)
  if (sum) {
    lines.push(`  sent=${sum.sent} received=${sum.received ?? "?"} packet-loss=${sum["packet-loss"] ?? "?"}`)
    if (sum.time && sum.sent) lines.push(`  avg-rtt=${sum.time}`)
  }
  return lines.join("\n")
}

function fmtTraceroute(rows: unknown): string {
  if (!Array.isArray(rows)) return typeof rows === "string" ? rows : JSON.stringify(rows, null, 2)
  const hdr = " # ADDRESS                                    LOSS   LAST     AVG"
  const lines = [hdr]
  rows.forEach((row, i) => {
    const r = row as Record<string, string | undefined>
    const addr = String(r.address ?? r.host ?? r["from-address"] ?? "?")
    const loss = String(r.loss ?? r["packet-loss"] ?? "—")
    const last = String(r["last"] ?? r.time ?? "—")
    const avg = String(r.avg ?? r["avg-rtt"] ?? "—")
    lines.push(
      ` ${String(i + 1).padStart(2)} ${addr.padEnd(40)} ${loss.padEnd(6)} ${last.padStart(8)} ${avg.padStart(8)}`,
    )
  })
  return lines.join("\n")
}

function fmtBandwidth(rows: Array<Record<string, string>>): string {
  if (rows.length === 0) return "(no bandwidth-test output)"
  const lines = rows.map((r) => {
    const tx = r["tx-current"] ?? r["tx-total-average"] ?? ""
    const rx = r["rx-current"] ?? r["rx-total-average"] ?? ""
    const sec = r["test-duration"] ?? ""
    const parts = [`tx=${tx}`, `rx=${rx}`]
    if (sec) parts.push(`t=${sec}`)
    return `  ${parts.join("  ")}`
  })
  return ["bandwidth-test:", ...lines].join("\n")
}

function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string {
  const isTrue = (v: unknown) => {
    const s = String(v ?? "").trim().toLowerCase()
    return s === "true" || s === "yes"
  }
  const matches = routes.filter((r) => {
    const dst = String(r["dst-address"] ?? "").trim()
    if (!dst) return false
    // Критично: учитывать только реально ACTIVE маршруты из /ip/route.
    if (!isTrue(r.active)) return false
    const rt = String((r as unknown as Record<string, unknown>)["routing-table"] ?? "").trim().toLowerCase()
    if (!(rt === "" || rt === "main")) return false
    if (String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() === "true") return false
    if (String((r as unknown as Record<string, unknown>).inactive ?? "false").toLowerCase() === "true") return false
    return ipMatchesRoute(destIp, dst)
  })
  if (matches.length === 0) {
    return `no route for ${destIp} (active routes checked: ${routes.length})`
  }
  matches.sort((a, b) => {
    const da = parseDstRoute(String(a["dst-address"] ?? ""))
    const db = parseDstRoute(String(b["dst-address"] ?? ""))
    const maskCmp = (db?.maskBits ?? 0) - (da?.maskBits ?? 0)
    if (maskCmp !== 0) return maskCmp
    const distA = Number(a.distance ?? 255)
    const distB = Number(b.distance ?? 255)
    if (distA !== distB) return distA - distB
    const aHasGw = String(a.gateway ?? "").trim().length > 0 ? 0 : 1
    const bHasGw = String(b.gateway ?? "").trim().length > 0 ? 0 : 1
    return aHasGw - bHasGw
  })
  const best = matches[0]!
  const lines = [
    `lookup ${destIp} → best match:`,
    `  dst-address: ${best["dst-address"] ?? "—"}`,
    `  gateway: ${best.gateway ?? best.interface ?? "—"}`,
    `  distance: ${best.distance ?? "—"}`,
    `  routing-mark: ${best["routing-mark"] ?? "main"}`,
  ]
  return lines.join("\n")
}

async function dnsLookupText(name: string, type: string): Promise<string> {
  const t = type.toUpperCase()
  const lines: string[] = [`;; QUESTION: ${name} ${t}`, ""]
  try {
    if (t === "A") {
      const addrs = await dns.resolve4(name)
      addrs.forEach((a) => lines.push(`${name}.  IN  A  ${a}`))
    } else if (t === "AAAA") {
      const addrs = await dns.resolve6(name)
      addrs.forEach((a) => lines.push(`${name}.  IN  AAAA  ${a}`))
    } else if (t === "MX") {
      const mx = await dns.resolveMx(name)
      mx.sort((a, b) => a.priority - b.priority)
      mx.forEach((m) => lines.push(`${name}.  IN  MX  ${m.priority} ${m.exchange}`))
    } else if (t === "NS") {
      const ns = await dns.resolveNs(name)
      ns.forEach((n) => lines.push(`${name}.  IN  NS  ${n}`))
    } else if (t === "TXT") {
      const tx = await dns.resolveTxt(name)
      tx.forEach((chunks) => lines.push(`${name}.  IN  TXT  "${chunks.join("")}"`))
    } else if (t === "CNAME") {
      const c = await dns.resolveCname(name)
      lines.push(`${name}.  IN  CNAME  ${c}`)
    } else if (t === "PTR") {
      const raw = name.trim()
      if (/^[\d.]+$/.test(raw)) {
        const hosts = await dns.reverse(raw)
        hosts.forEach((h) => lines.push(`${raw}.in-addr.arpa.  IN  PTR  ${h}`))
      } else {
        const ptr = await dns.resolvePtr(raw.includes(".arpa") ? raw : `${raw}.in-addr.arpa`)
        ptr.forEach((p) => lines.push(`${raw}.  IN  PTR  ${p}`))
      }
    } else {
      lines.push(`;; тип «${t}» не поддержан в живом режиме (используйте A, AAAA, MX, NS, TXT, CNAME, PTR)`)
    }
  } catch (e) {
    lines.push(`;; ERROR: ${e instanceof Error ? e.message : String(e)}`)
    lines.push(";; (резолв выполняется на хосте бекенда, не на MikroTik)")
  }
  return lines.join("\n")
}

async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: string | null): Promise<string> {
  const sizes = [1500, 1492, 1480, 1476, 1472, 1468, 1400, 1280, 1024, 576]
  const lines: string[] = [`MTU discovery (do-not-fragment ping) → ${address}`, ""]
  let mtuFound = 0
  for (const size of sizes) {
    try {
      const body: Record<string, string> = {
        address,
        count: "1",
        size: String(size),
        interval: "0.2s",
        /** REST /tool/ping: как в CLI — `do-not-fragment=yes`, не `dont-fragment` */
        "do-not-fragment": "true",
      }
      if (srcIpv4) body["src-address"] = srcIpv4
      const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000)
      const timeout = rows.some((r) => r.status === "timeout")
      if (!timeout) {
        lines.push(`  ${String(size).padStart(4)}  ✓ ok`)
        mtuFound = size
        break
      }
      lines.push(`  ${String(size).padStart(4)}  ✗ fragment needed / timeout`)
    } catch (e) {
      lines.push(`  ${String(size).padStart(4)}  ✗ ${e instanceof Error ? e.message : String(e)}`)
    }
  }
  lines.push("")
  lines.push(mtuFound > 0 ? `MTU (DF): ~${mtuFound} bytes` : "MTU: could not determine")
  return lines.join("\n")
}

function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
  if (explicitDstId != null && Number.isFinite(explicitDstId)) {
    const s = db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1).all()[0]
    if (s) return s
  }
  const norm = remoteHost.trim().toLowerCase()
  return db.select().from(servers).where(eq(servers.enabled, true)).all()
    .find((x) => x.host.trim().toLowerCase() === norm)
}

const probesRoutes: FastifyPluginAsyncZod = async (app) => {
  app.post("/servers/:id/probes/run", async (req, reply) => {
    const sid = parseServerId(String((req.params as { id?: string }).id ?? ""))
    if (sid === null) return reply.status(400).send({ error: "Invalid server id" })

    const server = db.select().from(servers).where(eq(servers.id, sid)).limit(1).all()[0]
    if (!server) return reply.status(404).send({ error: "Server not found" })

    const body = req.body as {
      tool?: string
      target?: string
      pingCount?: number
      pingSize?: number
      pingTtl?: number
      traceProto?: string
      traceMaxHops?: number
      /** Таймаут одной пробы: 800ms, 1s или 00:00:01 (в API уходит как HH:MM:SS) */
      traceHopTimeout?: string
      /** Число проб на хоп (1–3). Для REST рекомендуется 1 из‑за лимита сессии ~60 с */
      traceProbeCount?: number
      /** Резолвить адреса хопов в имена (RouterOS: use-dns yes|no → REST true|false) */
      traceUseDns?: boolean
      dnsType?: string
      bwRemoteAddress?: string
      dstServerId?: number
      bwProto?: string
      bwDuration?: number
    }

    const tool = String(body.tool ?? "").trim() as
      | "ping"
      | "traceroute"
      | "bandwidth"
      | "dns"
      | "route"
      | "mtu"
    const client = MikrotikClient.fromServer(server)
    const srcIpv4 = await resolveRosSrcIpv4(server.host)

    try {
      let output = ""

      switch (tool) {
        case "ping": {
          const target = String(body.target ?? "").trim()
          if (!target) return reply.status(400).send({ error: "target required" })
          const count = Math.min(100, Math.max(1, Number(body.pingCount) || 5))
          const size = Math.min(8192, Math.max(28, Number(body.pingSize) || 64))
          const ttl = Math.min(255, Math.max(1, Number(body.pingTtl) || 64))
          const pingBody: Record<string, string> = {
            address: target,
            count: String(count),
            size: String(size),
            ttl: String(ttl),
            interval: "0.2s",
          }
          if (srcIpv4) pingBody["src-address"] = srcIpv4
          const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000)
          output = fmtPingRouterOs(rows, target)
          break
        }
        case "traceroute": {
          const target = String(body.target ?? "").trim()
          if (!target) return reply.status(400).send({ error: "target required" })
          const proto = body.traceProto === "udp" || body.traceProto === "tcp" ? body.traceProto : "icmp"
          const maxHopsRequested = Math.min(64, Math.max(1, Number(body.traceMaxHops) || 30))
          const probeCount = Math.min(3, Math.max(1, Number(body.traceProbeCount) || 1))
          const hopMs = parseTraceHopInputToMs(body.traceHopTimeout)
          const hopTimeoutRos = traceHopTimeoutForApi(body.traceHopTimeout)
          const budgetMs = ROS_REST_SESSION_MAX_MS - 5000
          const worstMsPerHop = probeCount * hopMs
          const maxFeasibleHops = Math.max(1, Math.floor(budgetMs / worstMsPerHop))
          const effectiveMaxHops = Math.min(maxHopsRequested, maxFeasibleHops)
          const traceWallMs = ROS_REST_SESSION_MAX_MS
          const useDns = Boolean(body.traceUseDns)
          const traceBody: Record<string, string> = {
            address: target,
            protocol: proto,
            "max-hops": String(effectiveMaxHops),
            timeout: hopTimeoutRos,
            count: String(probeCount),
            "use-dns": useDns ? "true" : "false",
          }
          if (srcIpv4) traceBody["src-address"] = srcIpv4
          const disconnectAbort = new AbortController()
          const onClientClose = () => disconnectAbort.abort()
          req.raw.once("close", onClientClose)
          try {
            const sig = mergeAbortSignals(disconnectAbort.signal, abortAfterMs(traceWallMs))
            const rows = await client.post<unknown>("/tool/traceroute", traceBody, traceWallMs, sig)
            const note =
              effectiveMaxHops < maxHopsRequested
                ? `;; REST API RouterOS: сессия ~60 с (параметры traceroute не продлевают лимит). max-hops снижен с ${maxHopsRequested} до ${effectiveMaxHops} (count=${probeCount}, timeout=${hopTimeoutRos}).\n`
                : ""
            output = note + fmtTraceroute(rows)
          } finally {
            req.raw.off("close", onClientClose)
          }
          break
        }
        case "bandwidth": {
          const remote = String(body.bwRemoteAddress ?? "").trim()
          if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" })
          const dst = resolveBtestPeer(remote, body.dstServerId)
          if (!dst) {
            return reply.status(400).send({
              error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId",
            })
          }
          const protocol = body.bwProto === "udp" ? "udp" : "tcp"
          const durationSec = Math.max(3, Math.min(120, Number(body.bwDuration) || 10))
          const rows = await client.bandwidthTest({
            address: remote,
            user: dst.username,
            password: dst.password,
            protocol,
            direction: "both",
            durationSec,
          })
          output = fmtBandwidth(rows)
          break
        }
        case "dns": {
          const target = String(body.target ?? "").trim()
          if (!target) return reply.status(400).send({ error: "target required" })
          const dtype = String(body.dnsType ?? "A").trim() || "A"
          output = await dnsLookupText(target, dtype)
          break
        }
        case "route": {
          const target = String(body.target ?? "").trim()
          if (!target) return reply.status(400).send({ error: "target required" })
          const routes = await client.getIpRoutes()
          output = fmtRouteLookup(target, routes)
          break
        }
        case "mtu": {
          const target = String(body.target ?? "").trim()
          if (!target) return reply.status(400).send({ error: "target required" })
          output = await mtuDiscover(client, target, srcIpv4)
          break
        }
        default:
          return reply.status(400).send({ error: `unknown tool: ${tool}` })
      }

      return reply.send({ output })
    } catch (err) {
      const msg =
        err instanceof DOMException && err.name === "AbortError"
          ? "Запрос отменён (клиент закрыл соединение или истёк лимит времени для traceroute)."
          : err instanceof Error
            ? err.message
            : String(err)
      return reply.send({ output: `error: ${msg}` })
    }
  })
}

export default probesRoutes
