"use client"

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDataSource } from "@/lib/data-source"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { OpsPanel } from "@/components/ops-panel"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
  Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
} from "@/components/ui/sheet"
import {
  BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, LoaderCircleIcon,
  CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
  TimerIcon, WifiOffIcon, TrendingDownIcon,
  EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon,
  Trash2Icon,
  PencilIcon,
  ChevronsUpDownIcon,
  SearchIcon,
  AlertCircleIcon,
  ChevronUpIcon,
  ChevronDownIcon,
  PlugIcon,
  UnplugIcon,
} from "lucide-react"
import { Flag, countryName } from "@/components/flag"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { toast } from "sonner"

// ─── types ────────────────────────────────────────────────────────────────────

type AlertType     = "gre-tunnel" | "bgp-peer" | "bgp-prefix" | "gre-client" | "server" | "rtt" | "loss" | "traffic"
type AlertSeverity = "critical" | "warning" | "info"
type AlertCooldown = "1м" | "5м" | "15м" | "1ч" | "4ч" | "24ч"
type RecoveryMode = "always" | "never" | "conditional"

interface AlertRule {
  id: string
  name: string
  type: AlertType
  /** Сводная строка (как с бэкенда) */
  target: string
  /** Объекты правила (OR); если пусто — используется `target` */
  targets: string[]
  groupId: string | null
  /** Сводная подпись условий в списке */
  condition: string
  /** Все условия (OR); при отсутствии — трактуем как `[condition]` */
  conditions: string[]
  severity: AlertSeverity
  enabled: boolean
  cooldown: AlertCooldown
  /** Секунды: уведомление только если условие не пропало всё это время; null — выкл. */
  confirmStabilitySec: number | null
  recoveryMode?: RecoveryMode
  recoveryStabilitySec?: number | null
  lastFired: string | null   // relative time string
  chatId: string             // override; empty = use global
}

interface AlertGroup {
  id: string
  name: string
  combineMode: "any" | "all"
  enabled: boolean
  cooldownOverride: AlertCooldown | null
}

interface HistoryEntry {
  id: string
  ruleName: string
  severity: AlertSeverity
  message: string
  time: string
  sent: boolean
}

interface TelegramConfig {
  token: string
  chatId: string
  /** Положительное число — тема супергруппы (forum), Bot API: `message_thread_id` */
  messageThreadId: string
  connected: boolean
}

interface AlertsApiRule {
  id: string
  name: string
  type: AlertType
  target: string
  targets?: string[]
  groupId?: string | null
  condition: string
  conditions?: string[]
  severity: AlertSeverity
  enabled: boolean
  cooldown: AlertCooldown
  confirmStabilitySec?: number | null
  recoveryMode?: RecoveryMode
  recoveryStabilitySec?: number | null
  chatId: string
  lastFiredAt: string | null
}

interface AlertsApiGroup {
  id: string
  name: string
  combineMode: "any" | "all"
  enabled: boolean
  cooldownOverride: AlertCooldown | null
}

interface AlertsApiHistoryEntry {
  id: string
  ruleName: string
  severity: AlertSeverity
  message: string
  sent: boolean
  firedAt: string
}

interface AlertsServerRow {
  name: string
  host: string
  site: string
  country: string
  type: string
}

/** Каталог GRE для карточек объекта; `targetLabel` = значение `target` в правиле. */
interface AlertsGreTunnelRow {
  targetLabel: string
  tunnelName: string
  routerName: string
  country: string
  site: string
  remoteAddress: string
  status: "up" | "down" | "degraded" | string
  comment?: string
}

interface AlertsMeta {
  servers: string[]
  greClients: string[]
  probeTargets: string[]
  rttLossTargets: string[]
  trafficServers: string[]
  /** Каталог для карточек «Объект» (сервер / GRE-клиент / трафик) */
  serversDetail?: AlertsServerRow[]
  /** Собирается на клиенте из `/api/bgp/sessions` в live-режиме */
  bgpPeers?: string[]
  /** Подписи «имя туннеля / сервер» из `/api/filters/gre-tunnels` + `/api/servers` (как на /gre) */
  greTunnelTargets?: string[]
  /** Расширенные поля для карточек выбора туннеля */
  greTunnelDetail?: AlertsGreTunnelRow[]
}

/** Минимальный ответ BGP для подписей «роутер · сосед». */
interface BgpSessionListRow {
  serverName: string
  name: string
}

function makeApiFetch(backendUrl: string) {
  return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
    return requestJson<T>(backendUrl, path, init)
  }
}

function formatRelativeRu(iso: string): string {
  const t = Date.parse(iso)
  if (!Number.isFinite(t)) return iso
  const sec = Math.max(0, Math.floor((Date.now() - t) / 1000))
  if (sec < 45) return "только что"
  if (sec < 3600) return `${Math.max(1, Math.floor(sec / 60))} мин назад`
  if (sec < 86400) return `${Math.max(1, Math.floor(sec / 3600))} ч назад`
  const d = Math.floor(sec / 86400)
  return `${d} д назад`
}

function uniqStrings(xs: string[]): string[] {
  const o: string[] = []
  const s = new Set<string>()
  for (const x of xs) {
    const k = x.trim()
    if (!k || s.has(k)) continue
    s.add(k)
    o.push(k)
  }
  return o
}

/** Сводка условий для колонки `condition` в API / списке правил. */
function summarizeConditionsUi(lines: string[]): string {
  const u = uniqStrings(lines.map((t) => t.trim()).filter(Boolean))
  if (u.length === 0) return "—"
  if (u.length === 1) return u[0]!
  if (u.length <= 3) return u.join(" · ")
  return `${u[0]} · ${u[1]} · +${u.length - 2}`
}

function mapApiRules(rows: AlertsApiRule[]): AlertRule[] {
  return rows.map((r) => {
    const targets = r.targets?.length ? r.targets : r.target.trim() ? [r.target.trim()] : []
    const conditions =
      r.conditions && r.conditions.length > 0
        ? r.conditions.map((c) => c.trim()).filter(Boolean)
        : r.condition.trim()
          ? [r.condition.trim()]
          : []
    return {
      id: r.id,
      name: r.name,
      type: r.type,
      target: r.target,
      targets,
      groupId: r.groupId ?? null,
      condition: r.condition,
      conditions,
      severity: r.severity,
      enabled: r.enabled,
      cooldown: r.cooldown,
      confirmStabilitySec:
        r.confirmStabilitySec != null && r.confirmStabilitySec > 0 ? r.confirmStabilitySec : null,
      recoveryMode: r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always",
      recoveryStabilitySec:
        r.recoveryStabilitySec != null && r.recoveryStabilitySec > 0 ? r.recoveryStabilitySec : null,
      chatId: r.chatId ?? "",
      lastFired: r.lastFiredAt ? formatRelativeRu(r.lastFiredAt) : null,
    }
  })
}

function mapApiGroups(rows: AlertsApiGroup[]): AlertGroup[] {
  return rows.map((g) => ({
    id: g.id,
    name: g.name,
    combineMode: g.combineMode,
    enabled: g.enabled,
    cooldownOverride: g.cooldownOverride,
  }))
}

function mapApiHistory(rows: AlertsApiHistoryEntry[]): HistoryEntry[] {
  return rows.map((e) => ({
    id: e.id,
    ruleName: e.ruleName,
    severity: e.severity,
    message: e.message,
    time: formatRelativeRu(e.firedAt),
    sent: e.sent,
  }))
}

function toApiRuleBody(r: AlertRule) {
  const targets = r.targets.length > 0 ? r.targets : r.target.trim() ? [r.target.trim()] : []
  const conditions =
    r.conditions.length > 0
      ? r.conditions.map((c) => c.trim()).filter(Boolean)
      : r.condition.trim()
        ? [r.condition.trim()]
        : []
  return {
    id: r.id,
    name: r.name,
    type: r.type,
    target: targets[0] ?? r.target,
    targets,
    groupId: r.groupId ?? null,
    condition: conditions[0] ?? r.condition,
    conditions,
    severity: r.severity,
    enabled: r.enabled,
    cooldown: r.cooldown,
    confirmStabilitySec: r.confirmStabilitySec ?? null,
    recoveryMode: r.recoveryMode ?? "always",
    recoveryStabilitySec: r.recoveryStabilitySec ?? null,
    chatId: r.chatId ?? "",
  }
}

function toApiGroupBody(g: AlertGroup) {
  return {
    id: g.id,
    name: g.name,
    combineMode: g.combineMode,
    enabled: g.enabled,
    cooldownOverride: g.cooldownOverride,
  }
}

/** Снимок полей формы правила (без id / lastFired / enabled). */
interface RuleFormSnapshot {
  name: string
  type: AlertType
  targets: string[]
  groupId: string | null
  /** Шаблоны из TYPE_CONDITIONS (для типов с порогом — с «порога») */
  conditions: string[]
  threshold: string
  severity: AlertSeverity
  cooldown: AlertCooldown
  /** null — без задержки подтверждения */
  confirmStabilitySec: number | null
  recoveryMode?: RecoveryMode
  recoveryStabilitySec?: number | null
  chatId: string
}

function inferThresholdConditionTemplate(type: AlertType, line: string): string {
  const t = line.trim().toLowerCase()
  if (type === "rtt") return t.includes("<") ? "< порога" : "> порога"
  if (type === "loss") return "> порога"
  if (type === "traffic") return /tx\s*</i.test(line) ? "TX < порога" : "RX < порога"
  return line.trim()
}

/** Разбор сохранённого условия с порогом обратно в поля формы. */
function ruleToFormSnapshot(r: AlertRule): RuleFormSnapshot {
  const unit = THRESHOLD_UNIT[r.type]
  const rawConds =
    r.conditions.length > 0
      ? [...r.conditions]
      : r.condition.trim()
        ? [r.condition.trim()]
        : []
  let threshold = ""
  let conditions: string[] = []
  if (unit && (r.type === "rtt" || r.type === "loss" || r.type === "traffic")) {
    for (const line of rawConds) {
      const m = line.match(/(\d+(?:[.,]\d+)?)/)
      if (m && !threshold) threshold = m[1]!.replace(",", ".")
      conditions.push(inferThresholdConditionTemplate(r.type, line))
    }
    conditions = uniqStrings(conditions)
  } else {
    conditions = uniqStrings(rawConds.map((x) => x.trim()).filter(Boolean))
  }
  const targets = r.targets.length > 0 ? r.targets : r.target.trim() ? [r.target.trim()] : []
  return {
    name: r.name,
    type: r.type,
    targets,
    groupId: r.groupId ?? null,
    conditions,
    threshold,
    severity: r.severity,
    cooldown: r.cooldown,
    confirmStabilitySec: r.confirmStabilitySec ?? null,
    recoveryMode: r.recoveryMode ?? "always",
    recoveryStabilitySec: r.recoveryStabilitySec ?? null,
    chatId: r.chatId ?? "",
  }
}

/** Предлагаемое название по типу, объекту и условию. */
function suggestAlertRuleName(
  type: AlertType,
  targets: string[],
  conditions: string[],
  threshold: string,
): string {
  const typeLabel = TYPE_META[type].label
  const tgt =
    targets.length === 0
      ? "…"
      : targets.length === 1
        ? targets[0]!.trim() || "…"
        : `${targets.length} объектов`
  const unit = THRESHOLD_UNIT[type]
  let cond =
    conditions.length === 0
      ? "…"
      : conditions.length === 1
        ? conditions[0]!.trim() || "…"
        : `${conditions.length} условий`
  if (unit && threshold.trim() && cond.includes("порога")) {
    cond = cond.replace("порога", `${threshold.trim()} ${unit}`)
  }
  const base = `${typeLabel}: ${tgt} — ${cond}`
  return base.length > 110 ? `${base.slice(0, 107)}…` : base
}

function emptyTypeTargets(): Record<AlertType, string[]> {
  return {
    "gre-tunnel": [],
    "bgp-peer": [],
    "bgp-prefix": [],
    "gre-client": [],
    server: [],
    rtt: [],
    loss: [],
    traffic: [],
  }
}

/** Live: только данные из meta (и bgpPeers), без моковых подписей. */
function buildLiveTypeTargets(meta: AlertsMeta): Record<AlertType, string[]> {
  const srv = uniqStrings(meta.servers ?? [])
  const bgp = uniqStrings(meta.bgpPeers ?? [])
  const probes = uniqStrings(meta.probeTargets ?? [])
  const rttLoss = meta.rttLossTargets.length ? uniqStrings(meta.rttLossTargets) : probes
  const traffic = meta.trafficServers.length ? uniqStrings(meta.trafficServers) : srv
  const greClients = uniqStrings([...(meta.greClients ?? []), ...srv])
  const greTunnels = uniqStrings(meta.greTunnelTargets ?? [])
  return {
    "gre-tunnel": greTunnels,
    "bgp-peer": bgp,
    "bgp-prefix": [],
    "gre-client": greClients,
    server: srv,
    rtt: rttLoss,
    loss: rttLoss,
    traffic,
  }
}

const ALERT_TYPES_ORDER: AlertType[] = [
  "server",
  "bgp-peer",
  "bgp-prefix",
  "gre-tunnel",
  "gre-client",
  "rtt",
  "loss",
  "traffic",
]

const TYPE_EVENT_CARD_DESC: Record<AlertType, string> = {
  "gre-tunnel": "Смена состояния GRE-туннеля между узлами",
  "bgp-peer":   "Разрыв или восстановление BGP-сессии",
  "bgp-prefix": "Появление или отзыв префикса",
  "gre-client": "Подключение или отключение GRE-клиента",
  "server":     "Доступность и деградация сервера в каталоге",
  "rtt":        "Порог задержки по ping-пробе",
  "loss":       "Порог потерь по ping-пробе",
  "traffic":    "Низкий RX/TX по данным сбора трафика",
}

// ─── static config ────────────────────────────────────────────────────────────

const TYPE_META: Record<AlertType, {
  Icon: React.FC<{ className?: string }>
  label: string
  iconClass: string
  bg: string
}> = {
  "gre-tunnel": { Icon: CableIcon,        label: "GRE-туннель",    iconClass: "text-violet-500",  bg: "bg-violet-500/10"  },
  "bgp-peer":   { Icon: NetworkIcon,      label: "BGP-сосед",      iconClass: "text-blue-500",    bg: "bg-blue-500/10"    },
  "bgp-prefix": { Icon: RouteIcon,        label: "BGP-префикс",    iconClass: "text-sky-500",     bg: "bg-sky-500/10"     },
  "gre-client": { Icon: UserIcon,         label: "GRE-клиент",     iconClass: "text-purple-500",  bg: "bg-purple-500/10"  },
  "server":     { Icon: ServerIcon,       label: "Сервер",         iconClass: "text-slate-500",   bg: "bg-slate-500/10"   },
  "rtt":        { Icon: TimerIcon,        label: "Задержка (RTT)", iconClass: "text-amber-500",   bg: "bg-amber-500/10"   },
  "loss":       { Icon: WifiOffIcon,      label: "Потери пакетов", iconClass: "text-orange-500",  bg: "bg-orange-500/10"  },
  "traffic":    { Icon: TrendingDownIcon, label: "Низкий трафик",  iconClass: "text-rose-500",    bg: "bg-rose-500/10"    },
}

const SEVERITY_META: Record<AlertSeverity, { label: string; dot: string; badge: string; chip: string }> = {
  critical: {
    label: "Критическое",
    dot:   "bg-red-500",
    badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
    chip:  "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
  },
  warning: {
    label: "Предупреждение",
    dot:   "bg-amber-400",
    badge: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
    chip:  "border-amber-400 bg-amber-500/10 text-amber-600 dark:text-amber-400",
  },
  info: {
    label: "Информационное",
    dot:   "bg-blue-500",
    badge: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
    chip:  "border-blue-400 bg-blue-500/10 text-blue-600 dark:text-blue-400",
  },
}

const TYPE_TARGETS: Record<AlertType, string[]> = {
  "gre-tunnel": ["gre-ivanov-01 / core-01", "gre-ivanov-01 / lab-01", "gre-ivanov-02 / core-01", "gre-ivanov-03 / lab-01", "gre-petrov-01 / core-01", "gre-petrov-01 / lab-01", "gre-kozlov-01 / core-01"],
  "bgp-peer":   ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
  "bgp-prefix": ["любой префикс", "185.13.0.0/22", "77.88.8.0/24", "8.8.8.0/24", "1.1.1.0/24"],
  "gre-client": ["gre-ivanov-01", "gre-ivanov-02", "gre-ivanov-03", "gre-petrov-01", "gre-kozlov-01"],
  "server":     ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
  "rtt":        ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
  "loss":       ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
  "traffic":    ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
}

function splitGreTargetLabel(label: string): { tunnel: string; router: string } {
  const i = label.indexOf(" / ")
  if (i > 0) {
    return { tunnel: label.slice(0, i).trim(), router: label.slice(i + 3).trim() }
  }
  return { tunnel: label.trim(), router: "" }
}

function demoGreTunnelCatalog(): AlertsGreTunnelRow[] {
  const sites = ["MSK", "SPB", "FRA", "AMS", "SGP"] as const
  const countries = ["RU", "RU", "DE", "NL", "SG"] as const
  return TYPE_TARGETS["gre-tunnel"].map((label, idx) => {
    const { tunnel, router } = splitGreTargetLabel(label)
    return {
      targetLabel: label,
      tunnelName: tunnel,
      routerName: router || "—",
      country: countries[idx % countries.length] ?? "RU",
      site: sites[idx % sites.length] ?? "MSK",
      remoteAddress: `10.${(idx % 220) + 1}.0.2`,
      status: idx % 6 === 3 ? "degraded" : idx % 6 === 5 ? "down" : "up",
    }
  })
}

const GRE_CARD_STATUS: Record<string, { label: string; dot: string }> = {
  up:       { label: "Поднят",     dot: "bg-emerald-500" },
  degraded: { label: "Деградация", dot: "bg-amber-500" },
  down:     { label: "Offline",    dot: "bg-red-500" },
}

/** ISO 3166-1 alpha-2 для `Flag`; иначе null. */
function iso2CountryCode(country: string): string | null {
  const t = country.replace(/\s/g, "").toUpperCase()
  if (!t || t === "—" || t === "-") return null
  if (t.length === 2 && /^[A-Z]{2}$/.test(t)) return t
  return null
}

/** Вторая строка в сводке / подсказка на карточке для типов без серверного/GRE-каталога. */
function discreteTargetSubtitle(type: AlertType, target: string): string | null {
  const t = target.trim()
  if (!t) return null
  if (type === "bgp-peer") {
    const i = t.indexOf(":")
    if (i > 0) {
      const peer = t.slice(i + 1).trim()
      if (peer) return `Сосед: ${peer}`
    }
  }
  if (type === "rtt" || type === "loss") {
    const i = t.indexOf("→")
    if (i > 0) {
      const right = t.slice(i + 1).trim()
      if (right) return `Цель: ${right}`
    }
  }
  return null
}

/** Иконка строки условия (согласована с текстом варианта). */
function conditionLeadingIcon(condition: string): {
  Icon: React.FC<{ className?: string }>
  className: string
} {
  const c = condition.toLowerCase()
  if (c.includes("разорвал")) return { Icon: UnplugIcon, className: "text-red-400/90" }
  if (c.includes("восстановил сесс")) return { Icon: PlugIcon, className: "text-emerald-500/90" }
  if (c.includes("offline") || c.includes("отключ")) return { Icon: WifiOffIcon, className: "text-red-400/90" }
  if (c.includes("отозван")) return { Icon: RouteIcon, className: "text-orange-400/90" }
  if (c.includes("получен")) return { Icon: RouteIcon, className: "text-sky-400/90" }
  if (c.includes("восстанов") || c.includes("подключ")) return { Icon: CheckIcon, className: "text-emerald-500/90" }
  if (c.includes("degraded")) return { Icon: AlertCircleIcon, className: "text-amber-500/90" }
  if (c.includes(">")) return { Icon: ChevronUpIcon, className: "text-amber-500/90" }
  if (c.includes("<")) return { Icon: ChevronDownIcon, className: "text-sky-400/90" }
  return { Icon: BellIcon, className: "text-muted-foreground" }
}

const TYPE_CONDITIONS: Record<AlertType, string[]> = {
  "gre-tunnel": ["перешёл в offline", "восстановился"],
  "bgp-peer":   ["разорвал сессию", "восстановил сессию"],
  "bgp-prefix": ["был отозван", "был получен"],
  "gre-client": ["отключился", "подключился"],
  "server":     ["перешёл в offline", "перешёл в degraded", "восстановился"],
  "rtt":        ["> порога", "< порога"],
  "loss":       ["> порога"],
  "traffic":    ["RX < порога", "TX < порога"],
}

const THRESHOLD_UNIT: Partial<Record<AlertType, string>> = {
  rtt:     "мс",
  loss:    "%",
  traffic: "Мбит/с",
}

const COOLDOWNS: AlertCooldown[] = ["1м", "5м", "15м", "1ч", "4ч", "24ч"]

/** Задержка подтверждения: уведомление только если условие держится без «отмены» (пропало срабатывание). */
const CONFIRM_STABILITY_OPTIONS: { value: number | null; label: string }[] = [
  { value: null, label: "Нет" },
  { value: 30, label: "30 с" },
  { value: 60, label: "1 мин" },
  { value: 120, label: "2 мин" },
  { value: 300, label: "5 мин" },
  { value: 900, label: "15 мин" },
  { value: 3600, label: "1 ч" },
]

function formatConfirmStabilityShort(sec: number | null | undefined): string | null {
  if (sec == null || sec <= 0) return null
  const o = CONFIRM_STABILITY_OPTIONS.find((x) => x.value === sec)
  return o?.label ?? `${sec} с`
}

// ─── mock data ────────────────────────────────────────────────────────────────

const INIT_RULES: AlertRule[] = [
  { id: "r1", name: "GRE-туннель Иванов offline",   type: "gre-tunnel", target: "gre-ivanov-01 / core-01",      targets: ["gre-ivanov-01 / core-01"],      groupId: null, condition: "перешёл в offline",    conditions: ["перешёл в offline"],    severity: "critical", enabled: true,  cooldown: "5м",  confirmStabilitySec: null, lastFired: "3ч назад",   chatId: "" },
  { id: "r2", name: "BGP-сосед core-01 down",       type: "bgp-peer",   target: "mt-msk-core-01: uplink-1",     targets: ["mt-msk-core-01: uplink-1"],     groupId: null, condition: "разорвал сессию",       conditions: ["разорвал сессию"],       severity: "critical", enabled: true,  cooldown: "15м", confirmStabilitySec: null, lastFired: "1д назад",   chatId: "" },
  { id: "r3", name: "Высокая задержка FRA",          type: "rtt",        target: "mt-fra-edge-01 → 8.8.8.8",    targets: ["mt-fra-edge-01 → 8.8.8.8"],    groupId: null, condition: "> 120 мс",              conditions: ["> 120 мс"],              severity: "warning",  enabled: true,  cooldown: "15м", confirmStabilitySec: null, lastFired: "45м назад",  chatId: "" },
  { id: "r4", name: "Потери пакетов AMS",            type: "loss",       target: "mt-ams-edge-01 → 1.1.1.1",    targets: ["mt-ams-edge-01 → 1.1.1.1"],    groupId: null, condition: "> 5%",                  conditions: ["> 5%"],                  severity: "warning",  enabled: true,  cooldown: "5м",  confirmStabilitySec: null, lastFired: null,         chatId: "" },
  { id: "r5", name: "Сервер SGP offline",            type: "server",     target: "mt-sgp-edge-01",               targets: ["mt-sgp-edge-01"],               groupId: null, condition: "перешёл в offline",    conditions: ["перешёл в offline"],    severity: "critical", enabled: true,  cooldown: "1ч",  confirmStabilitySec: null, lastFired: "2д назад",   chatId: "" },
  { id: "r6", name: "BGP-префикс отозван core-01",  type: "bgp-prefix", target: "любой префикс",                targets: ["любой префикс"],                groupId: null, condition: "был отозван",           conditions: ["был отозван"],           severity: "info",     enabled: true,  cooldown: "5м",  confirmStabilitySec: null, lastFired: "6ч назад",   chatId: "-1009876543210" },
  { id: "r7", name: "GRE-клиент Козлов offline",    type: "gre-client", target: "gre-kozlov-01",                targets: ["gre-kozlov-01"],                groupId: null, condition: "отключился",            conditions: ["отключился"],            severity: "warning",  enabled: false, cooldown: "1ч",  confirmStabilitySec: null, lastFired: null,         chatId: "" },
  { id: "r8", name: "Низкий трафик AMS-edge",       type: "traffic",    target: "mt-ams-edge-01",               targets: ["mt-ams-edge-01"],               groupId: null, condition: "RX < 10 Мбит/с",       conditions: ["RX < 10 Мбит/с"],       severity: "info",     enabled: false, cooldown: "4ч",  confirmStabilitySec: null, lastFired: null,         chatId: "" },
]

const INIT_GROUPS: AlertGroup[] = []

const INIT_HISTORY: HistoryEntry[] = [
  { id: "h1", ruleName: "Высокая задержка FRA",         severity: "warning",  message: "RTT mt-fra-edge-01 → 8.8.8.8: 137 мс (порог 120 мс)",  time: "45м назад",  sent: true  },
  { id: "h2", ruleName: "GRE-туннель Иванов offline",   severity: "critical", message: "gre-ivanov-01 / core-01 перешёл в offline",              time: "3ч назад",   sent: true  },
  { id: "h3", ruleName: "BGP-префикс отозван core-01",  severity: "info",     message: "Префикс 185.13.0.0/22 отозван на mt-msk-core-01",        time: "6ч назад",   sent: true  },
  { id: "h4", ruleName: "BGP-сосед core-01 down",       severity: "critical", message: "BGP-сессия с mt-msk-core-01 разорвана",                  time: "1д назад",   sent: true  },
  { id: "h5", ruleName: "Сервер SGP offline",            severity: "critical", message: "mt-sgp-edge-01 недоступен, ping timeout",                time: "2д назад",   sent: false },
]

type AlertPresetDef = {
  id: string
  title: string
  description: string
  build: (serverNames: string[]) => Omit<AlertRule, "id" | "lastFired" | "enabled">[]
}

const ALERT_PRESETS: AlertPresetDef[] = [
  {
    id: "srv-offline-pack",
    title: "Offline по серверам",
    description: "По одному правилу на каждый сервер из каталога (до 24 шт.).",
    build: (serverNames) =>
      serverNames.slice(0, 24).map((name) => ({
        name:         `Сервер ${name}: offline`,
        type:         "server" as const,
        target:       name,
        targets:      [name],
        groupId:      null,
        condition:    "перешёл в offline",
        conditions:   ["перешёл в offline"],
        severity:     "critical" as const,
        cooldown:     "5м" as const,
        confirmStabilitySec: null,
        chatId:       "",
      })),
  },
  {
    id: "rtt-8888-pack",
    title: "RTT → 8.8.8.8",
    description: "До 6 правил: задержка выше 100 мс для первых серверов.",
    build: (serverNames) =>
      serverNames.slice(0, 6).map((name) => ({
        name:         `RTT ${name} → 8.8.8.8`,
        type:         "rtt" as const,
        target:       `${name} → 8.8.8.8`,
        targets:      [`${name} → 8.8.8.8`],
        groupId:      null,
        condition:    "> 100 мс",
        conditions:   ["> 100 мс"],
        severity:     "warning" as const,
        cooldown:     "15м" as const,
        confirmStabilitySec: null,
        chatId:       "",
      })),
  },
]

const INIT_TG: TelegramConfig = {
  token: "",
  chatId: "",
  messageThreadId: "",
  connected: false,
}

// ─── small helpers ────────────────────────────────────────────────────────────

function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
  return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
}

function FieldHint({ children }: { children: React.ReactNode }) {
  return <p className="text-xs text-muted-foreground mt-1.5">{children}</p>
}

function SeverityDot({ severity }: { severity: AlertSeverity }) {
  return <span className={cn("inline-block size-2 rounded-full shrink-0", SEVERITY_META[severity].dot)} />
}

function SeverityBadge({ severity }: { severity: AlertSeverity }) {
  return (
    <span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap", SEVERITY_META[severity].badge)}>
      {severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"}
    </span>
  )
}

// ─── alert rule row ───────────────────────────────────────────────────────────

function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }: {
  rule: AlertRule
  onToggle: (id: string, enabled: boolean) => void
  onDelete: (id: string) => void
  onEdit?: (id: string) => void
  interactionsDisabled?: boolean
}) {
  const { Icon, iconClass, bg } = TYPE_META[rule.type]

  return (
    <div className={cn(
      "flex items-center gap-3 px-4 py-3 hover:bg-muted/20 transition-colors group",
      !rule.enabled && "opacity-55",
    )}>
      {/* toggle */}
      <FormToggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />

      {/* severity dot */}
      <SeverityDot severity={rule.severity} />

      {/* type icon */}
      <div className={cn("size-7 rounded-md flex items-center justify-center shrink-0", bg)}>
        <Icon className={cn("size-3.5", iconClass)} />
      </div>

      {/* name + target */}
      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium truncate">{rule.name}</p>
        <p className="text-[11px] text-muted-foreground truncate">
          {TYPE_META[rule.type].label} ·{" "}
          <span className="font-mono">
            {rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target}
          </span>
          {rule.groupId ? (
            <span className="text-muted-foreground/80"> · группа</span>
          ) : null}
          {" · "}{rule.condition}
        </p>
      </div>

      {/* cooldown */}
      <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
        {rule.cooldown}
      </span>
      {formatConfirmStabilityShort(rule.confirmStabilitySec) ? (
        <span
          className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground shrink-0 max-w-[72px] truncate"
          title={`Подтверждение: ${formatConfirmStabilityShort(rule.confirmStabilitySec)}`}
        >
          ⏱ {formatConfirmStabilityShort(rule.confirmStabilitySec)}
        </span>
      ) : null}

      {/* chat override badge */}
      {rule.chatId && (
        <span className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground hidden xl:inline shrink-0">
          #{rule.chatId.slice(-6)}
        </span>
      )}

      {/* last fired */}
      <span className={cn(
        "text-[11px] shrink-0 w-[80px] text-right",
        rule.lastFired ? "text-muted-foreground" : "text-muted-foreground/40",
      )}>
        {rule.lastFired ?? "—"}
      </span>

      {/* severity badge */}
      <div className="hidden lg:block shrink-0">
        <SeverityBadge severity={rule.severity} />
      </div>

      {/* actions (visible on hover) */}
      <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
        {onEdit && (
          <button
            type="button"
            disabled={interactionsDisabled}
            onClick={() => onEdit(rule.id)}
            className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
            title="Изменить"
          >
            <PencilIcon className="size-3.5" />
          </button>
        )}
        <button
          type="button"
          disabled={interactionsDisabled}
          onClick={() => onDelete(rule.id)}
          className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40">
          <Trash2Icon className="size-3.5" />
        </button>
      </div>
    </div>
  )
}

// ─── telegram config card ─────────────────────────────────────────────────────

function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTelegram, onTestTelegram }: {
  cfg: TelegramConfig
  onChange: (c: TelegramConfig) => void
  /** В режиме live: токен в БД есть, в поле не подставляем */
  tokenConfigured?: boolean
  liveSaveBusy?: boolean
  onSaveTelegram?: () => void | Promise<void>
  onTestTelegram?: () => void | Promise<void>
}) {
  const [showToken, setShowToken] = useState(false)
  const [testing, setTesting]     = useState(false)
  const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null)

  const handleTest = async () => {
    setTesting(true)
    setTestResult(null)
    try {
      if (onTestTelegram) {
        await onTestTelegram()
        setTestResult("ok")
      } else {
        await new Promise((r) => setTimeout(r, 450))
        setTestResult(cfg.connected ? "ok" : "fail")
      }
    } catch {
      setTestResult("fail")
    } finally {
      setTesting(false)
      setTimeout(() => setTestResult(null), 4000)
    }
  }

  return (
    <OpsPanel
      title={<span className="flex items-center gap-2"><span className="text-base">✈️</span> Telegram</span>}
      headerRight={
          <span className={cn(
            "flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
            cfg.connected
              ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
              : "bg-muted text-muted-foreground",
          )}>
            <span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
            {cfg.connected ? "Подключён" : "Не настроен"}
          </span>
      }
      contentClassName="px-4 pb-4 flex flex-col gap-3"
    >

        {/* bot token */}
        <div>
          <FieldLabel>Bot Token</FieldLabel>
          <div className="flex gap-1.5">
            <Input
              type={showToken ? "text" : "password"}
              value={cfg.token}
              onChange={e => onChange({ ...cfg, token: e.target.value })}
              autoComplete="new-password"
              name="telegram-bot-token"
              className="text-xs font-mono h-8"
              placeholder={tokenConfigured && !cfg.token ? "Токен сохранён в БД — введите новый для замены" : "1234567890:AAF..."}
            />
            <button
              onClick={() => setShowToken(v => !v)}
              className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
              {showToken ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
            </button>
            <button
              onClick={() => navigator.clipboard.writeText(cfg.token)}
              className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
              <CopyIcon className="size-3.5" />
            </button>
          </div>
        </div>

        {/* default chat id */}
        <div>
          <FieldLabel>Chat ID по умолчанию</FieldLabel>
          <Input
            value={cfg.chatId}
            onChange={e => onChange({ ...cfg, chatId: e.target.value })}
            className="text-xs font-mono h-8"
            placeholder="-100xxxxxxxxxx"
          />
          <p className="text-[10px] text-muted-foreground mt-1">
            Для групп используйте отрицательный ID. Каждое правило может переопределить.
          </p>
        </div>

        <div>
          <FieldLabel>ID темы (message_thread_id)</FieldLabel>
          <Input
            value={cfg.messageThreadId}
            onChange={e => onChange({ ...cfg, messageThreadId: e.target.value.replace(/\D/g, "") })}
            className="text-xs font-mono h-8"
            inputMode="numeric"
            placeholder="Напр. 12345 — только для супергрупп с темами"
          />
          <FieldHint>
            Опционально: номер топика в форум-супергруппе. Пусто — сообщение в общий чат группы.
          </FieldHint>
        </div>

        {onSaveTelegram && (
          <Button
            size="sm" className="w-full h-8 text-xs"
            disabled={!!liveSaveBusy}
            onClick={() => { void onSaveTelegram() }}
          >
            {liveSaveBusy ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
            {liveSaveBusy ? "Сохранение…" : "Сохранить в БД"}
          </Button>
        )}

        {/* test button */}
        <Button
          size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
          onClick={() => { void handleTest() }} disabled={testing}>
          {testing ? (
            <RefreshCwIcon className="size-3.5 animate-spin" />
          ) : testResult === "ok" ? (
            <CheckIcon className="size-3.5 text-emerald-500" />
          ) : testResult === "fail" ? (
            <XIcon className="size-3.5 text-destructive" />
          ) : (
            <SendIcon className="size-3.5" />
          )}
          {testing ? "Отправка…"
            : testResult === "ok" ? "Сообщение отправлено"
            : testResult === "fail" ? "Ошибка отправки"
            : "Отправить тестовое сообщение"}
        </Button>
    </OpsPanel>
  )
}

// ─── history card ─────────────────────────────────────────────────────────────

function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
  return (
    <OpsPanel title="Журнал срабатываний" contentClassName="px-4 pb-4">
        {entries.length === 0 ? (
          <p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
        ) : (
          <div className="flex flex-col gap-2.5">
            {entries.map(e => (
              <div key={e.id} className="flex gap-2.5 items-start">
                <SeverityDot severity={e.severity} />
                <div className="flex-1 min-w-0">
                  <p className="text-[11px] font-medium leading-tight truncate">{e.ruleName}</p>
                  <p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-2">{e.message}</p>
                </div>
                <div className="text-right shrink-0">
                  <p className="text-[10px] text-muted-foreground whitespace-nowrap">{e.time}</p>
                  <span className={cn(
                    "text-[9px] font-medium px-1 py-0.5 rounded mt-0.5 inline-block",
                    e.sent
                      ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
                      : "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
                  )}>
                    {e.sent ? "✓ отправлено" : "✗ ошибка"}
                  </span>
                </div>
              </div>
            ))}
          </div>
        )}
    </OpsPanel>
  )
}

const SERVER_ROLES_RU: Record<string, string> = {
  "jump-host": "Транзитный узел",
  "exit-node": "Выходной узел",
  "home-router": "Домашний роутер",
}

function serverRowLabel(s: AlertsServerRow): string {
  return (s.name?.trim() || s.host || "").trim()
}

/** Типы, где «объект» — сервер из каталога: показываем развёрнутые карточки. */
const OBJECT_TYPES_WITH_SERVER_CARDS: AlertType[] = ["server", "gre-client", "traffic"]

/** Карточка сервера: поиск, флаг страны, имя, host, площадка, роль (как GRE-карточки). */
function ServerObjectCardPicker({
  label,
  rows,
  selected,
  onToggle,
  hideLabel,
  /** Родитель задаёт высоту и скролл (блок в шите) */
  listUnconstrained,
}: {
  label: string
  rows: AlertsServerRow[]
  selected: string[]
  onToggle: (serverTitle: string) => void
  /** Один общий заголовок снаружи (блок «шагов») */
  hideLabel?: boolean
  listUnconstrained?: boolean
}) {
  const [query, setQuery] = useState("")

  useEffect(() => {
    queueMicrotask(() => {
      setQuery("")
    })
  }, [rows])

  const filteredRows = useMemo(() => {
    const s = query.trim().toLowerCase()
    if (!s) return rows
    return rows.filter((row) => {
      const cc = iso2CountryCode(row.country)
      const blob = [
        serverRowLabel(row),
        row.host,
        row.site,
        row.country,
        cc ? countryName(cc) : "",
        SERVER_ROLES_RU[row.type] ?? row.type,
      ]
        .join(" ")
        .toLowerCase()
      return blob.includes(s)
    })
  }, [rows, query])

  return (
    <div className="flex flex-col gap-1.5 min-w-0">
      {!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
      {rows.length === 0 ? (
        <p className="text-[10px] text-muted-foreground leading-snug">Нет серверов для выбора</p>
      ) : (
        <>
          <div className="relative shrink-0">
            <SearchIcon
              className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
              aria-hidden
            />
            <Input
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              autoComplete="off"
              name="alert-server-search"
              placeholder="Поиск: сервер, площадка, IP, роль…"
              className="h-8 pl-8 pr-2 text-xs"
              aria-label="Поиск по списку серверов"
            />
          </div>
          <div
            role="listbox"
            aria-label={label}
            className={cn(
              "flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
              listUnconstrained
                ? "overflow-visible"
                : "max-h-[min(40vh,16rem)] overflow-y-auto",
            )}
          >
            {filteredRows.length === 0 ? (
              <p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
            ) : null}
            {filteredRows.map((s) => {
              const title = serverRowLabel(s)
              const active = selected.includes(title)
              const loc = [s.site, s.country].filter(Boolean).join(" · ")
              const cc = iso2CountryCode(s.country)
              return (
                <button
                  key={`${title}\0${s.host}`}
                  type="button"
                  role="option"
                  aria-selected={active}
                  onClick={() => onToggle(title)}
                  className={cn(
                    "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
                    active
                      ? "border-primary bg-primary/8 ring-1 ring-primary/30"
                      : "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
                  )}
                >
                  <div className="flex items-start gap-2 min-w-0 w-full">
                    <div className="flex items-start gap-1.5 min-w-0 flex-1">
                    <ServerIcon className="size-3.5 shrink-0 text-muted-foreground mt-px" aria-hidden />
                    <div className="min-w-0 flex-1 space-y-0.5">
                      <div className="flex items-center gap-1.5 min-w-0">
                        {cc ? (
                          <Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
                        ) : null}
                        <span className="text-[11px] font-semibold text-foreground truncate">{title}</span>
                      </div>
                      <p className="text-[10px] font-mono text-muted-foreground truncate">{s.host || "—"}</p>
                      {loc ? (
                        <p className="text-[10px] text-muted-foreground/90 truncate">{loc}</p>
                      ) : null}
                      <p className="text-[9px] text-muted-foreground/80">
                        {SERVER_ROLES_RU[s.type] ?? s.type}
                      </p>
                    </div>
                    </div>
                    {active ? (
                      <CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-px" aria-hidden />
                    ) : null}
                  </div>
                </button>
              )
            })}
          </div>
        </>
      )}
    </div>
  )
}

/** Карточки GRE: страна/площадка, роутер, имя интерфейса, remote, статус. */
function GreTunnelObjectCardPicker({
  label,
  rows,
  selected,
  onToggle,
  hideLabel,
  listUnconstrained,
}: {
  label: string
  rows: AlertsGreTunnelRow[]
  selected: string[]
  onToggle: (targetLabel: string) => void
  hideLabel?: boolean
  listUnconstrained?: boolean
}) {
  const [query, setQuery] = useState("")

  useEffect(() => {
    queueMicrotask(() => {
      setQuery("")
    })
  }, [rows])

  const filteredRows = useMemo(() => {
    const s = query.trim().toLowerCase()
    if (!s) return rows
    return rows.filter((r) => {
      const cc = iso2CountryCode(r.country)
      const hay = [
        r.tunnelName,
        r.routerName,
        r.site,
        r.country,
        r.remoteAddress,
        r.targetLabel,
        r.comment ?? "",
        cc ? countryName(cc) : "",
      ]
        .join(" ")
        .toLowerCase()
      return hay.includes(s)
    })
  }, [rows, query])

  return (
    <div className="flex flex-col gap-1.5 min-w-0">
      {!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
      {rows.length === 0 ? (
        <p className="text-[10px] text-muted-foreground leading-snug">Нет туннелей для выбора</p>
      ) : (
        <>
          <div className="relative shrink-0">
            <SearchIcon
              className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
              aria-hidden
            />
            <Input
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              autoComplete="off"
              name="alert-gre-search"
              placeholder="Поиск: имя, роутер, IP, площадка…"
              className="h-8 pl-8 pr-2 text-xs"
              aria-label="Поиск по списку GRE-туннелей"
            />
          </div>
          <div
            role="listbox"
            aria-label={label}
            className={cn(
              "flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
              listUnconstrained ? "overflow-visible" : "max-h-[min(40vh,16rem)] overflow-y-auto",
            )}
          >
            {filteredRows.length === 0 ? (
              <p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
            ) : null}
            {filteredRows.map((r) => {
            const active = selected.includes(r.targetLabel)
            const st = GRE_CARD_STATUS[r.status] ?? { label: String(r.status), dot: "bg-muted-foreground" }
            const cc = iso2CountryCode(r.country)
            const siteLine =
              r.site && r.site !== "—"
                ? r.site
                : cc
                  ? countryName(cc)
                  : "—"
            return (
              <button
                key={`${r.targetLabel}\0${r.remoteAddress}`}
                type="button"
                role="option"
                aria-selected={active}
                onClick={() => onToggle(r.targetLabel)}
                className={cn(
                  "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
                  active
                    ? "border-primary bg-primary/8 ring-1 ring-primary/30"
                    : "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
                )}
              >
                <div className="flex items-start justify-between gap-1.5 w-full min-w-0">
                  <div className="flex items-start gap-1.5 min-w-0 flex-1">
                    <CableIcon className="size-3.5 shrink-0 text-violet-500/90 mt-px" aria-hidden />
                    <div className="min-w-0 flex-1 space-y-0.5">
                      <div className="flex items-center gap-1.5 min-w-0">
                        {cc ? (
                          <Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
                        ) : null}
                        <span className="text-[11px] font-medium text-foreground truncate">{siteLine}</span>
                      </div>
                      <p className="text-[11px] text-foreground truncate">
                        <span className="text-muted-foreground/85">Роутер</span>{" "}
                        <span className="font-medium">{r.routerName}</span>
                      </p>
                      <p className="text-[11px] truncate">
                        <span className="text-muted-foreground/85">Название</span>{" "}
                        <span className="font-mono font-semibold text-foreground">{r.tunnelName}</span>
                      </p>
                      {r.remoteAddress && r.remoteAddress !== "—" ? (
                        <p className="text-[10px] font-mono text-muted-foreground/90 truncate">
                          {r.remoteAddress}
                        </p>
                      ) : null}
                    </div>
                  </div>
                  <span className="flex items-center gap-1.5 shrink-0 text-[9px] text-muted-foreground pt-px">
                    <span className="flex items-center gap-1">
                      <span className={cn("size-1.5 rounded-full shrink-0", st.dot)} aria-hidden />
                      <span className="whitespace-nowrap leading-none">{st.label}</span>
                    </span>
                    {active ? (
                      <CheckIcon className="size-4 shrink-0 text-primary opacity-70" aria-hidden />
                    ) : null}
                  </span>
                </div>
                {r.comment ? (
                  <p className="text-[9px] text-muted-foreground/80 line-clamp-2 mt-1 pl-5 border-t border-border/30 pt-1 leading-snug">
                    {r.comment}
                  </p>
                ) : null}
              </button>
            )
          })}
          </div>
        </>
      )}
    </div>
  )
}

/** Карточки объекта для типов со строковым списком (BGP, префиксы, RTT/loss и т.д.): поиск + иконка типа. */
function StringOptionCardPicker({
  label,
  options,
  selected,
  onToggle,
  hideLabel,
  listUnconstrained,
  ruleType,
}: {
  label: string
  options: string[]
  selected: string[]
  onToggle: (opt: string) => void
  hideLabel?: boolean
  listUnconstrained?: boolean
  ruleType: AlertType
}) {
  const [query, setQuery] = useState("")
  const m = TYPE_META[ruleType]
  const Icon = m.Icon

  useEffect(() => {
    queueMicrotask(() => {
      setQuery("")
    })
  }, [options])

  const filtered = useMemo(() => {
    const s = query.trim().toLowerCase()
    if (!s) return options
    return options.filter((o) => o.toLowerCase().includes(s))
  }, [options, query])

  return (
    <div className="flex flex-col gap-1.5 min-w-0">
      {!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
      {options.length === 0 ? (
        <p className="text-[10px] text-muted-foreground leading-snug">Нет объектов для выбора</p>
      ) : (
        <>
          <div className="relative shrink-0">
            <SearchIcon
              className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
              aria-hidden
            />
            <Input
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              autoComplete="off"
              name="alert-target-search"
              placeholder="Поиск по списку объектов…"
              className="h-8 pl-8 pr-2 text-xs"
              aria-label="Поиск по списку объектов"
            />
          </div>
          <div
            role="listbox"
            aria-label={label}
            className={cn(
              "flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
              listUnconstrained ? "overflow-visible" : "max-h-[min(40vh,16rem)] overflow-y-auto",
            )}
          >
            {filtered.length === 0 ? (
              <p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
            ) : null}
            {filtered.map((opt) => {
              const active = selected.includes(opt)
              const hint = discreteTargetSubtitle(ruleType, opt)
              return (
                <button
                  key={opt}
                  type="button"
                  role="option"
                  aria-selected={active}
                  onClick={() => onToggle(opt)}
                  className={cn(
                    "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
                    active
                      ? "border-primary bg-primary/8 ring-1 ring-primary/30"
                      : "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
                  )}
                >
                  <div className="flex items-start gap-2 min-w-0 w-full">
                    <div className="flex items-start gap-1.5 min-w-0 flex-1">
                    <Icon className={cn("size-3.5 shrink-0 mt-px", m.iconClass)} aria-hidden />
                    <div className="min-w-0 flex-1 space-y-0.5">
                      <p className="text-[11px] font-semibold text-foreground break-words">{opt}</p>
                      {hint ? (
                        <p className="text-[10px] text-muted-foreground/90 truncate">{hint}</p>
                      ) : null}
                    </div>
                    </div>
                    {active ? (
                      <CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-px" aria-hidden />
                    ) : null}
                  </div>
                </button>
              )
            })}
          </div>
        </>
      )}
    </div>
  )
}

/** Выбор условия: как объекты — несколько карточек, OR по условиям. */
function ConditionOptionCardPicker({
  label,
  conditions,
  selected,
  onToggle,
  hideLabel,
  listUnconstrained,
  ruleType,
}: {
  label: string
  conditions: string[]
  selected: string[]
  onToggle: (v: string) => void
  hideLabel?: boolean
  listUnconstrained?: boolean
  ruleType: AlertType
}) {
  const [query, setQuery] = useState("")
  const m = TYPE_META[ruleType]

  useEffect(() => {
    queueMicrotask(() => {
      setQuery("")
    })
  }, [conditions])

  const filtered = useMemo(() => {
    const s = query.trim().toLowerCase()
    if (!s) return conditions
    return conditions.filter((o) => o.toLowerCase().includes(s))
  }, [conditions, query])

  return (
    <div className="flex flex-col gap-1.5 min-w-0">
      {!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
      {conditions.length === 0 ? (
        <p className="text-[10px] text-muted-foreground leading-snug">Нет условий для этого типа</p>
      ) : (
        <>
          <div className="relative shrink-0">
            <SearchIcon
              className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
              aria-hidden
            />
            <Input
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              autoComplete="off"
              name="alert-condition-search"
              placeholder="Поиск по вариантам условия…"
              className="h-8 pl-8 pr-2 text-xs"
              aria-label="Поиск по условиям"
            />
          </div>
          <div
            role="listbox"
            aria-label={label}
            aria-multiselectable
            className={cn(
              "flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
              listUnconstrained ? "overflow-visible" : "max-h-[min(36vh,14rem)] overflow-y-auto",
            )}
          >
            {filtered.length === 0 ? (
              <p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
            ) : null}
            {filtered.map((opt) => {
              const active = selected.includes(opt)
              const { Icon: CondIcon, className: condIconCls } = conditionLeadingIcon(opt)
              return (
                <button
                  key={opt}
                  type="button"
                  role="option"
                  aria-selected={active}
                  onClick={() => onToggle(opt)}
                  className={cn(
                    "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
                    active
                      ? "border-primary bg-primary/8 ring-1 ring-primary/30"
                      : "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
                  )}
                >
                  <div className="flex items-start gap-2 min-w-0 w-full">
                    <div
                      className={cn(
                        "flex h-7 min-w-[2.125rem] shrink-0 items-center justify-center gap-0.5 rounded-md px-1",
                        m.bg,
                      )}
                    >
                      <m.Icon className={cn("size-3 shrink-0", m.iconClass)} aria-hidden />
                      <CondIcon className={cn("size-3 shrink-0", condIconCls)} aria-hidden />
                    </div>
                    <div className="min-w-0 flex-1 space-y-0.5">
                      <p className="text-[11px] font-semibold text-foreground break-words">{opt}</p>
                      <p className="text-[9px] text-muted-foreground/85 leading-tight">{m.label}</p>
                    </div>
                    {active ? (
                      <CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-0.5" aria-hidden />
                    ) : null}
                  </div>
                </button>
              )
            })}
          </div>
        </>
      )}
    </div>
  )
}

/** Свернутый выбранный объект — развернуть список снова (без второй рамки: внешний контейнер уже обводит блок). */
function ObjectPickSummaryRow({
  ruleType,
  targets,
  server,
  greTunnel,
  onExpand,
}: {
  ruleType: AlertType
  targets: string[]
  server?: AlertsServerRow
  greTunnel?: AlertsGreTunnelRow
  onExpand: () => void
}) {
  const target = targets[0] ?? ""
  const multiHint = targets.length > 1 ? ` (+${targets.length - 1})` : ""

  if (greTunnel) {
    const cc = iso2CountryCode(greTunnel.country)
    const siteLine =
      greTunnel.site && greTunnel.site !== "—"
        ? greTunnel.site
        : cc
          ? countryName(cc)
          : "—"
    return (
      <button
        type="button"
        onClick={onExpand}
        className={cn(
          "group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
          "bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        )}
      >
        <CableIcon className="size-3.5 shrink-0 text-violet-500/85" aria-hidden />
        <div className="min-w-0 flex-1 text-left space-y-0.5">
          <div className="flex items-center gap-1.5 min-w-0">
            {cc ? (
              <Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
            ) : null}
            <p className="text-[11px] font-medium text-foreground truncate">{siteLine}</p>
          </div>
          <p className="text-[11px] text-muted-foreground truncate">
            <span className="text-foreground font-medium">{greTunnel.routerName}</span>
            <span className="mx-1 text-muted-foreground/45">·</span>
            <span className="font-mono text-foreground">{greTunnel.tunnelName}</span>
          </p>
        </div>
        <span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
          <ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
          Изменить
        </span>
      </button>
    )
  }

  if (server) {
    const cc = iso2CountryCode(server.country)
    const line2 = (() => {
      const h = (server.host ?? "").trim()
      if (h && h !== target.trim()) return { text: h, mono: true as const }
      const loc = [server.site, server.country].filter(Boolean).join(" · ")
      if (loc) return { text: loc, mono: false as const }
      const role = SERVER_ROLES_RU[server.type] ?? server.type
      if (role) return { text: role, mono: false as const }
      return null
    })()
    return (
      <button
        type="button"
        onClick={onExpand}
        className={cn(
          "group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
          "bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        )}
      >
        <ServerIcon className="size-3.5 shrink-0 text-muted-foreground group-hover:text-foreground" aria-hidden />
        <div className="min-w-0 flex-1 text-left space-y-0.5">
          <div className="flex items-center gap-1.5 min-w-0">
            {cc ? (
              <Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
            ) : null}
            <p className="text-[12px] font-medium text-foreground truncate">{target}{multiHint}</p>
          </div>
          {line2 ? (
            <p
              className={cn(
                "text-[11px] text-muted-foreground truncate",
                line2.mono && "font-mono",
              )}
            >
              {line2.text}
            </p>
          ) : null}
        </div>
        <span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
          <ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
          Изменить
        </span>
      </button>
    )
  }

  const m = TYPE_META[ruleType]
  const Icon = m.Icon
  const sub = discreteTargetSubtitle(ruleType, target)
  const joined = targets.length > 1 ? targets.join(", ") : target
  return (
    <button
      type="button"
      onClick={onExpand}
      className={cn(
        "group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
        "bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
      )}
    >
      <Icon className={cn("size-3.5 shrink-0", m.iconClass)} aria-hidden />
      <div className="min-w-0 flex-1 text-left space-y-0.5">
        <p className="text-[12px] font-medium text-foreground break-words">{joined}</p>
        {sub ? (
          <p className="text-[11px] text-muted-foreground truncate">{sub}</p>
        ) : null}
      </div>
      <span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
        <ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
        Изменить
      </span>
    </button>
  )
}

// ─── add rule sheet ───────────────────────────────────────────────────────────

const BLANK_FORM: RuleFormSnapshot = {
  name:      "",
  type:      "server",
  targets:   [],
  groupId:   null,
  conditions: [],
  threshold: "",
  severity:  "warning",
  cooldown:  "5м",
  confirmStabilitySec: null,
  chatId:    "",
}

type RuleTelegramTestPayload = {
  name: string
  targets: string[]
  conditionLine: string
  severity: AlertSeverity
  cooldown: AlertCooldown
  ruleChatId: string
}

function AddRuleSheet({
  open,
  onClose,
  onSave,
  typeTargets,
  isLive,
  serversCatalog,
  greTunnelCatalog,
  groups,
  initialForm,
  editRuleId,
  onTestRuleTelegram,
  testTelegramDisabled,
}: {
  open: boolean
  onClose: () => void
  /** editRuleId задан — обновление существующего правила */
  onSave: (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">, editRuleId: string | null) => void
  typeTargets: Record<AlertType, string[]>
  isLive: boolean
  serversCatalog: AlertsServerRow[]
  greTunnelCatalog: AlertsGreTunnelRow[]
  groups: AlertGroup[]
  initialForm: RuleFormSnapshot | null
  editRuleId: string | null
  /** Live: отправка тестового сообщения с текстом как у сохранённого правила (без записи в БД). */
  onTestRuleTelegram?: (payload: RuleTelegramTestPayload) => Promise<void>
  /** true — кнопка теста неактивна (например, бэкенд недоступен). */
  testTelegramDisabled?: boolean
}) {
  const [form, setForm] = useState<RuleFormSnapshot>(() => ({
    ...BLANK_FORM,
    ...(initialForm ?? {}),
  }))
  const nameTouchedRef = useRef(Boolean(editRuleId))
  /** Список объектов не показываем сразу — после фокуса/клика по другому полю шита. Редактирование с выбранными объектами — сразу. */
  const [objectPickerRevealed, setObjectPickerRevealed] = useState(
    () => Boolean(editRuleId && (initialForm?.targets?.length ?? 0) > 0),
  )
  const revealObjectPicker = useCallback(() => {
    setObjectPickerRevealed(true)
  }, [])
  /** Для списка объектов: после выбора сворачиваем список, ниже показываем условия. */
  const [objectExpanded, setObjectExpanded] = useState(
    () => !(initialForm?.targets?.length),
  )
  const [testTelegramBusy, setTestTelegramBusy] = useState(false)

  const targets    = typeTargets[form.type]
  const conditions = TYPE_CONDITIONS[form.type]
  const targetsEmpty = targets.length === 0

  const objectServerCardRows = useMemo(() => {
    if (!OBJECT_TYPES_WITH_SERVER_CARDS.includes(form.type)) return []
    if (!serversCatalog.length) return []
    const pick = new Set(targets)
    return serversCatalog.filter((s) => pick.has(serverRowLabel(s)))
  }, [form.type, serversCatalog, targets])

  const objectGreCardRows = useMemo(() => {
    if (form.type !== "gre-tunnel" || !greTunnelCatalog.length) return []
    const pick = new Set(targets)
    return greTunnelCatalog.filter((r) => pick.has(r.targetLabel))
  }, [form.type, greTunnelCatalog, targets])

  const useGreTunnelObjectCards = !targetsEmpty && objectGreCardRows.length > 0
  const useServerObjectCards = !targetsEmpty && objectServerCardRows.length > 0
  const unit       = THRESHOLD_UNIT[form.type]
  const allowManualTarget = targetsEmpty
  const targetOk = allowManualTarget ? Boolean(form.targets[0]?.trim()) : form.targets.length > 0
  const hasObject = form.targets.length > 0

  const objectSummaryServer = useMemo((): AlertsServerRow | undefined => {
    if (!useServerObjectCards) return undefined
    for (const t of form.targets) {
      const s = objectServerCardRows.find((x) => serverRowLabel(x) === t.trim())
      if (s) return s
    }
    return undefined
  }, [useServerObjectCards, objectServerCardRows, form.targets])

  const objectSummaryGre = useMemo((): AlertsGreTunnelRow | undefined => {
    if (!useGreTunnelObjectCards) return undefined
    for (const t of form.targets) {
      const r = objectGreCardRows.find((x) => x.targetLabel === t.trim())
      if (r) return r
    }
    return undefined
  }, [useGreTunnelObjectCards, objectGreCardRows, form.targets])

  const toggleTarget = useCallback((v: string) => {
    const t = v.trim()
    if (!t) return
    nameTouchedRef.current = false
    setForm((f) => {
      const set = new Set(f.targets)
      if (set.has(t)) set.delete(t)
      else set.add(t)
      const next = [...set]
      // Не сворачиваем после каждого клика по карточке — иначе мульти-выбор серверов невозможен без повторного «Изменить».
      queueMicrotask(() => {
        if (next.length === 0) setObjectExpanded(true)
      })
      return { ...f, targets: next }
    })
  }, [])

  useEffect(() => {
    if (!open) return
    if (nameTouchedRef.current) return
    const trivialEmpty = form.targets.length === 0 && form.conditions.length === 0
    queueMicrotask(() => {
      if (trivialEmpty) {
        if (!editRuleId) {
          setForm((f) => (f.name === "" ? f : { ...f, name: "" }))
        }
        return
      }
      const nextName = suggestAlertRuleName(form.type, form.targets, form.conditions, form.threshold)
      setForm((f) => (f.name === nextName ? f : { ...f, name: nextName }))
    })
  }, [open, editRuleId, form.type, form.targets, form.conditions, form.threshold])

  const expandedConditionLines = useMemo(
    () =>
      form.conditions.map((c) =>
        unit && form.threshold.trim() && c.includes("порога")
          ? c.replace("порога", `${form.threshold.trim()} ${unit}`)
          : c,
      ),
    [form.conditions, form.threshold, unit],
  )

  const conditionDisplay = expandedConditionLines.join("\n")

  const canSave =
    form.name.trim() && targetOk && form.conditions.length > 0 && (!unit || form.threshold)

  const handleTestRuleTelegram = async () => {
    if (!onTestRuleTelegram || !canSave || testTelegramBusy || testTelegramDisabled) return
    setTestTelegramBusy(true)
    try {
      const conditionLine = conditionDisplay || summarizeConditionsUi(form.conditions)
      await onTestRuleTelegram({
        name: form.name.trim(),
        targets: uniqStrings(form.targets),
        conditionLine,
        severity: form.severity,
        cooldown: form.cooldown,
        ruleChatId: form.chatId.trim(),
      })
      toast.success("Тестовое сообщение отправлено в Telegram.")
    } catch (e) {
      toast.error(e instanceof Error ? e.message : "Не удалось отправить тест")
    } finally {
      setTestTelegramBusy(false)
    }
  }

  const handleSave = () => {
    if (!canSave) return
    const uq = uniqStrings(form.targets)
    const targetSummary = uq.join(", ").slice(0, 240) || "—"
    const condResolved = uniqStrings(expandedConditionLines.map((c) => c.trim()).filter(Boolean))
    onSave({
      name:      form.name.trim(),
      type:      form.type,
      target:    targetSummary,
      targets:   uq,
      groupId:   form.groupId && groups.some((g) => g.id === form.groupId) ? form.groupId : null,
      condition: summarizeConditionsUi(condResolved),
      conditions: condResolved,
      severity:  form.severity,
      cooldown:  form.cooldown,
      confirmStabilitySec: form.confirmStabilitySec,
      chatId:    form.chatId.trim(),
    }, editRuleId)
    onClose()
  }

  const applySuggestedName = useCallback(() => {
    nameTouchedRef.current = false
    const trivialEmpty = form.targets.length === 0 && form.conditions.length === 0
    if (!editRuleId && trivialEmpty) {
      setForm((f) => ({ ...f, name: "" }))
      return
    }
    const nextName = suggestAlertRuleName(form.type, form.targets, form.conditions, form.threshold)
    setForm((f) => ({ ...f, name: nextName }))
  }, [editRuleId, form.type, form.targets, form.conditions, form.threshold])

  const toggleCondition = useCallback((v: string) => {
    const t = v.trim()
    if (!t) return
    nameTouchedRef.current = false
    setForm((f) => {
      const set = new Set(f.conditions)
      if (set.has(t)) set.delete(t)
      else set.add(t)
      const next = [...set]
      queueMicrotask(() => setObjectExpanded(false))
      return { ...f, conditions: next }
    })
  }, [])

  return (
    <Sheet open={open} onOpenChange={(o) => { if (!o) onClose() }}>
      {/* p-0 + gap-0: we control all spacing internally */}
      <SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-lg">

        {/* ── fixed header ── */}
        <SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
          <SheetTitle className="flex items-center gap-2 text-base">
            <BellIcon className="size-4" />
            {editRuleId ? "Изменение правила" : "Новое правило оповещения"}
          </SheetTitle>
        </SheetHeader>

        {/* ── scrollable body ── */}
        <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">

          {/* name */}
          <div className="flex flex-col gap-1.5">
            <div className="flex items-center justify-between gap-2">
              <FieldLabel>Название правила</FieldLabel>
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="h-7 text-[11px] px-2 text-muted-foreground shrink-0"
                onClick={() => {
                  revealObjectPicker()
                  applySuggestedName()
                }}
              >
                Сгенерировать
              </Button>
            </div>
            <Input
              placeholder="Подставится при выборе объекта или условия и будет обновляться при каждом шаге"
              value={form.name}
              onFocus={revealObjectPicker}
              onChange={e => {
                nameTouchedRef.current = true
                setForm((f) => ({ ...f, name: e.target.value }))
              }}
            />
            {!editRuleId && (
              <p className="text-[10px] text-muted-foreground">
                Название пересобирается при смене типа, объекта, условия или порога. После ручного ввода в поле автоподстановка отключается.
              </p>
            )}
          </div>

          {/* type — карточки как «Действие» на /filters */}
          <div className="flex flex-col gap-2">
            <FieldLabel>Тип события</FieldLabel>
            <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
              {ALERT_TYPES_ORDER.map((t) => {
                const m = TYPE_META[t]
                const { Icon, iconClass, bg } = m
                const active = form.type === t
                return (
                  <button
                    key={t}
                    type="button"
                    onFocus={revealObjectPicker}
                    onClick={() => {
                      nameTouchedRef.current = false
                      setObjectPickerRevealed(true)
                      setForm((f) => ({
                        ...f,
                        type: t,
                        targets: [],
                        conditions: [],
                        threshold: "",
                      }))
                      queueMicrotask(() => setObjectExpanded(true))
                    }}
                    className={cn(
                      "flex flex-col items-start gap-2 rounded-lg border px-3 py-3 text-left transition-all w-full",
                      active
                        ? "border-primary bg-primary/5 ring-1 ring-primary/30"
                        : "border-border hover:border-muted-foreground/40 hover:bg-muted/30",
                    )}
                  >
                    <div className="flex items-center gap-2 w-full min-w-0">
                      <div className={cn("size-8 rounded-md flex items-center justify-center shrink-0", bg)}>
                        <Icon className={cn("size-4", iconClass)} />
                      </div>
                      <span className={cn(
                        "text-xs font-semibold truncate flex-1",
                        active ? "text-primary" : "text-foreground",
                      )}>
                        {m.label}
                      </span>
                      {active && <CheckIcon className="size-3.5 shrink-0 text-primary" />}
                    </div>
                    <span className="text-[10px] text-muted-foreground leading-tight pl-0">
                      {TYPE_EVENT_CARD_DESC[t]}
                    </span>
                  </button>
                )
              })}
            </div>
          </div>

          <Separator />

          {/* Объект и условие — вертикально; после выбора объекта список сворачивается, условия появляются */}
          <div className="flex flex-col gap-4">
            {allowManualTarget ? (
              <div className="flex flex-col gap-1.5 min-w-0">
                <FieldLabel>Объект</FieldLabel>
                {!objectPickerRevealed ? (
                  <p className="text-[10px] text-muted-foreground leading-snug">
                    Поле ввода появится после клика или фокуса на названии правила, типе события, серьёзности,
                    пороге, группе или Chat ID.
                  </p>
                ) : (
                  <>
                    <Input
                      value={form.targets[0] ?? ""}
                      onChange={e => {
                        nameTouchedRef.current = false
                        const v = e.target.value.trim()
                        setForm(f => ({ ...f, targets: v ? [v] : [] }))
                        queueMicrotask(() => setObjectExpanded(!v))
                      }}
                      className="text-xs font-mono h-8"
                      placeholder={
                        isLive
                          ? "Точное имя объекта (сервер, сосед, префикс…)"
                          : "Или выберите из списка в демо-режиме"
                      }
                    />
                    {isLive && (
                      <p className="text-[10px] text-muted-foreground mt-0.5">
                        Нет готового списка для этого типа — укажите объект вручную (как в данных мониторинга).
                      </p>
                    )}
                  </>
                )}
              </div>
            ) : (
              <div className="flex flex-col gap-1.5 min-w-0">
                <FieldLabel>Объект</FieldLabel>
                <p className="text-[10px] text-muted-foreground leading-snug">
                  Можно выбрать несколько карточек — сработает при событии на любом из них (OR).
                </p>
                {!objectPickerRevealed ? (
                  <p className="text-[10px] text-muted-foreground leading-snug">
                    Список объектов появится после клика или фокуса на названии правила, типе события, серьёзности,
                    пороге, группе или Chat ID.
                  </p>
                ) : (
                  <div
                    className={cn(
                      "rounded-xl border border-border/80 bg-muted/10 overflow-hidden",
                      "transition-[background-color] duration-300",
                    )}
                  >
                    <div
                      className={cn(
                        "grid transition-[grid-template-rows] duration-500 ease-[cubic-bezier(0.25,1,0.5,1)]",
                        objectExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
                      )}
                    >
                      <div className="min-h-0 overflow-hidden">
                        <div className="p-3 max-h-[min(42vh,17rem)] overflow-y-auto overscroll-contain">
                          {useGreTunnelObjectCards ? (
                            <GreTunnelObjectCardPicker
                              hideLabel
                              listUnconstrained
                              label="Объект"
                              rows={objectGreCardRows}
                              selected={form.targets}
                              onToggle={toggleTarget}
                            />
                          ) : useServerObjectCards ? (
                            <ServerObjectCardPicker
                              hideLabel
                              listUnconstrained
                              label="Объект"
                              rows={objectServerCardRows}
                              selected={form.targets}
                              onToggle={toggleTarget}
                            />
                          ) : (
                            <StringOptionCardPicker
                              hideLabel
                              listUnconstrained
                              label="Объект"
                              ruleType={form.type}
                              options={targets}
                              selected={form.targets}
                              onToggle={toggleTarget}
                            />
                          )}
                        </div>
                      </div>
                    </div>
                    <div
                      className={cn(
                        "grid transition-[grid-template-rows] duration-400 ease-[cubic-bezier(0.25,1,0.5,1)]",
                        !objectExpanded && hasObject ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
                      )}
                    >
                      <div className="min-h-0 overflow-hidden">
                        <div className="p-3">
                          <ObjectPickSummaryRow
                            ruleType={form.type}
                            targets={form.targets}
                            server={objectSummaryGre ? undefined : objectSummaryServer}
                            greTunnel={objectSummaryGre}
                            onExpand={() => {
                              revealObjectPicker()
                              setObjectExpanded(true)
                            }}
                          />
                        </div>
                      </div>
                    </div>
                  </div>
                )}
              </div>
            )}

            <div className="flex flex-col gap-1.5 min-w-0">
              <FieldLabel className={cn(!hasObject && "text-muted-foreground")}>Условие</FieldLabel>
              {!hasObject ? (
                <p className="text-[10px] text-muted-foreground leading-snug">
                  {allowManualTarget
                    ? "Укажите объект выше — затем появится выбор условия."
                    : "Сначала выберите объект (можно несколько). Варианты условия появятся ниже; после выбора условия список объектов свернётся — снова развернуть кнопкой «Изменить»."}
                </p>
              ) : (
                <>
                  <p className="text-[10px] text-muted-foreground leading-snug">
                    Можно выбрать несколько карточек — уведомление при срабатывании любого из условий (OR), например offline и восстановление.
                  </p>
                  <div
                    className={cn(
                      "rounded-xl border border-border/80 bg-muted/10 overflow-hidden",
                      "transition-[opacity,transform,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
                      "opacity-100 translate-y-0 motion-reduce:transition-none",
                    )}
                  >
                    <div className="p-3 max-h-[min(40vh,16rem)] overflow-y-auto overscroll-contain">
                      <ConditionOptionCardPicker
                        hideLabel
                        listUnconstrained
                        label="Условие"
                        ruleType={form.type}
                        conditions={conditions}
                        selected={form.conditions}
                        onToggle={toggleCondition}
                      />
                    </div>
                  </div>
                </>
              )}
            </div>
          </div>

          {/* threshold — only for RTT / loss / traffic */}
          {unit && (
            <div className="flex flex-col gap-1.5">
              <FieldLabel>Пороговое значение</FieldLabel>
              <div className="flex items-center gap-0">
                <Input
                  type="number" min="0"
                  value={form.threshold}
                  onFocus={revealObjectPicker}
                  onChange={e => {
                    nameTouchedRef.current = false
                    setForm((f) => ({ ...f, threshold: e.target.value }))
                  }}
                  className="rounded-r-none"
                  placeholder="0"
                />
                <span className="h-8 px-3 flex items-center rounded-r-lg border border-l-0 border-input
                  bg-muted text-sm text-muted-foreground shrink-0">
                  {unit}
                </span>
              </div>
            </div>
          )}

          {/* severity — vertical radio cards */}
          <div className="flex flex-col gap-1.5">
            <FieldLabel>Серьёзность</FieldLabel>
            <div className="flex flex-col gap-1.5">
              {(["critical", "warning", "info"] as AlertSeverity[]).map(s => {
                const active = form.severity === s
                return (
                  <button
                    key={s}
                    type="button"
                    onFocus={revealObjectPicker}
                    onClick={() => {
                      revealObjectPicker()
                      setForm((f) => ({ ...f, severity: s }))
                    }}
                    className={cn(
                      "flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors w-full",
                      active ? SEVERITY_META[s].chip : "border-input hover:bg-muted/50 text-foreground",
                    )}>
                    <span className={cn("size-2.5 rounded-full shrink-0", SEVERITY_META[s].dot)} />
                    <div className="min-w-0">
                      <p className="text-sm font-medium leading-none">{SEVERITY_META[s].label}</p>
                      <p className="text-xs text-muted-foreground mt-0.5 leading-none">
                        {s === "critical" ? "Немедленное уведомление, звуковой сигнал"
                          : s === "warning" ? "Важное событие, тихое уведомление"
                          : "Информационное, без уведомления"}
                      </p>
                    </div>
                    {active && <CheckIcon className="size-4 ml-auto shrink-0 opacity-70" />}
                  </button>
                )
              })}
            </div>
          </div>

          {/* cooldown */}
          <div className="flex flex-col gap-1.5">
            <FieldLabel>Повторять не чаще чем</FieldLabel>
            <div className="flex gap-1.5 flex-wrap">
              {COOLDOWNS.map(c => (
                <button
                  key={c}
                  type="button"
                  onFocus={revealObjectPicker}
                  onClick={() => {
                    revealObjectPicker()
                    setForm((f) => ({ ...f, cooldown: c }))
                  }}
                  className={cn(
                    "px-3 py-1.5 rounded-lg border text-sm font-mono transition-colors",
                    form.cooldown === c
                      ? "border-primary bg-primary/10 text-primary font-semibold"
                      : "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
                  )}>
                  {c}
                </button>
              ))}
            </div>
          </div>

          {/* подтверждение стабильности (антидребезг) */}
          <div className="flex flex-col gap-1.5">
            <FieldLabel>Задержка перед отправкой</FieldLabel>
            <div className="flex gap-1.5 flex-wrap">
              {CONFIRM_STABILITY_OPTIONS.map((o) => {
                const active =
                  (o.value == null && (form.confirmStabilitySec == null || form.confirmStabilitySec <= 0)) ||
                  (o.value != null && form.confirmStabilitySec === o.value)
                return (
                  <button
                    key={o.label}
                    type="button"
                    onFocus={revealObjectPicker}
                    onClick={() => {
                      revealObjectPicker()
                      setForm((f) => ({ ...f, confirmStabilitySec: o.value }))
                    }}
                    className={cn(
                      "px-3 py-1.5 rounded-lg border text-sm transition-colors",
                      active
                        ? "border-primary bg-primary/10 text-primary font-semibold"
                        : "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
                    )}
                  >
                    {o.label}
                  </button>
                )
              })}
            </div>
            <FieldHint>
              Если за выбранное время условие перестанет выполняться (сигнал «отменится»), уведомление не отправится.
              Не зависит от cooldown: сначала ждём стабильность, затем проверяем cooldown после фактической отправки.
            </FieldHint>
          </div>

          <div className="flex flex-col gap-1.5">
            <FieldLabel>Группа правил (ANY / ALL)</FieldLabel>
            <select
              className={cn(
                "h-9 w-full rounded-md border border-input bg-background px-2 text-xs",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
              )}
              value={form.groupId ?? ""}
              onFocus={revealObjectPicker}
              onChange={(e) => {
                const v = e.target.value
                setForm((f) => ({ ...f, groupId: v ? v : null }))
              }}
            >
              <option value="">Без группы (отдельное уведомление)</option>
              {groups.map((g) => (
                <option key={g.id} value={g.id}>
                  {g.name} ({g.combineMode === "any" ? "ANY" : "ALL"})
                  {!g.enabled ? " · выкл." : ""}
                </option>
              ))}
            </select>
            <FieldHint>
              ANY — уведомление при срабатывании любого правила в группе. ALL — когда сработали все правила группы одновременно.
            </FieldHint>
          </div>

          <div className="flex flex-col gap-1.5">
            <FieldLabel>Chat ID — переопределить</FieldLabel>
            <Input
              value={form.chatId}
              onFocus={revealObjectPicker}
              onChange={e => setForm(f => ({ ...f, chatId: e.target.value }))}
              className="font-mono text-sm"
              placeholder="-100xxxxxxxxxx"
            />
            <FieldHint>Пусто → используется глобальный Chat ID из настроек Telegram</FieldHint>
          </div>

          {/* Telegram message preview */}
          {form.name && form.targets.length > 0 && form.conditions.length > 0 && (
            <div className="rounded-xl border border-border bg-muted/40 overflow-hidden">
              <div className="px-3 py-2 border-b border-border/60 flex items-center gap-1.5">
                <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
                  Предпросмотр сообщения
                </span>
              </div>
              <div className="px-3 py-2.5 font-mono text-xs leading-relaxed space-y-0.5">
                <p>
                  {form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"}
                  {" "}<span className="font-semibold">{form.name}</span>
                </p>
                <p className="text-muted-foreground">Объект: {form.targets.join(", ")}</p>
                <p className="text-muted-foreground whitespace-pre-wrap">
                  Событие: {conditionDisplay || summarizeConditionsUi(form.conditions)}
                </p>
                <p className="text-muted-foreground">Cooldown: {form.cooldown}</p>
                <p className="text-muted-foreground">
                  Chat: {form.chatId || "(глобальный)"}
                </p>
              </div>
            </div>
          )}
        </div>

        {/* ── fixed footer ── */}
        <SheetFooter className="shrink-0 px-6 py-4 border-t flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-stretch">
          <Button variant="outline" className="w-full sm:flex-1 min-h-9" onClick={onClose}>
            Отмена
          </Button>
          {isLive && onTestRuleTelegram ? (
            <Button
              type="button"
              variant="secondary"
              className="w-full sm:flex-1 min-h-9 inline-flex items-center justify-center gap-2"
              disabled={!canSave || testTelegramBusy || testTelegramDisabled}
              title={
                testTelegramDisabled
                  ? "Бэкенд недоступен"
                  : !canSave
                    ? "Заполните название, объект, условие и при необходимости порог"
                    : undefined
              }
              onClick={() => void handleTestRuleTelegram()}
            >
              {testTelegramBusy ? (
                <LoaderCircleIcon className="size-4 animate-spin shrink-0" aria-hidden />
              ) : (
                <SendIcon className="size-4 shrink-0" aria-hidden />
              )}
              Тест в Telegram
            </Button>
          ) : null}
          <Button className="w-full sm:flex-1 min-h-9" onClick={handleSave} disabled={!canSave}>
            {editRuleId ? "Сохранить изменения" : "Сохранить правило"}
          </Button>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}

// ─── page ─────────────────────────────────────────────────────────────────────

type SeverityFilter = AlertSeverity | "all"

export default function AlertsPage() {
  const { mode, backendUrl, backendStatus } = useDataSource()
  const isLive = mode === "live"
  const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])

  const [rules, setRules] = useState<AlertRule[]>(INIT_RULES)
  const [groups, setGroups] = useState<AlertGroup[]>(INIT_GROUPS)
  const [history, setHistory] = useState<HistoryEntry[]>(INIT_HISTORY)
  const [tg, setTg] = useState<TelegramConfig>(INIT_TG)
  const [tokenConfigured, setTokenConfigured] = useState(false)
  const [meta, setMeta] = useState<AlertsMeta | null>(null)

  const [sheetOpen, setSheetOpen] = useState(false)
  const [pendingPresetReplaceId, setPendingPresetReplaceId] = useState<string | null>(null)
  const [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
  const [sheetEditId, setSheetEditId] = useState<string | null>(null)
  const [sheetKey, setSheetKey] = useState(0)
  const [sevFilter, setSevFilter] = useState<SeverityFilter>("all")
  const [onlyActive, setOnlyActive] = useState(false)
  const [search, setSearch] = useState("")

  const [loadBusy, setLoadBusy] = useState(false)
  const [loadError, setLoadError] = useState<string | null>(null)
  const [rulesSaveBusy, setRulesSaveBusy] = useState(false)
  const [telegramSaveBusy, setTelegramSaveBusy] = useState(false)
  /** Пресеты и группы ANY/ALL — по умолчанию свёрнуты, чтобы не занимать место */
  const [extrasExpanded, setExtrasExpanded] = useState(false)
  const [hintsExpanded, setHintsExpanded] = useState(false)

  const typeTargets = useMemo(() => {
    if (!isLive) return TYPE_TARGETS
    if (!meta) return emptyTypeTargets()
    return buildLiveTypeTargets(meta)
  }, [isLive, meta])

  const serversCatalog = useMemo((): AlertsServerRow[] => {
    if (isLive && meta?.serversDetail?.length) return meta.serversDetail
    if (!isLive) {
      return TYPE_TARGETS.server.map((name, i) => ({
        name,
        host: `192.0.2.${(i % 220) + 10}`,
        site: ["MSK", "SPB", "FRA", "AMS", "SGP"][i % 5] ?? "",
        country: ["RU", "RU", "DE", "NL", "SG"][i % 5] ?? "",
        type: (["jump-host", "exit-node", "home-router"] as const)[i % 3] as string,
      }))
    }
    return []
  }, [isLive, meta])

  const greTunnelCatalog = useMemo((): AlertsGreTunnelRow[] => {
    if (isLive && meta?.greTunnelDetail?.length) return meta.greTunnelDetail
    if (!isLive) return demoGreTunnelCatalog()
    return meta?.greTunnelDetail ?? []
  }, [isLive, meta])

  const load = useCallback(async () => {
    if (!isLive) {
      setRules(INIT_RULES)
      setGroups(INIT_GROUPS)
      setHistory(INIT_HISTORY)
      setTg(INIT_TG)
      setTokenConfigured(false)
      setMeta(null)
      setLoadError(null)
      return
    }
    setLoadBusy(true)
    setLoadError(null)
    try {
      const data = await apiFetch<{
        telegram: {
          chatId: string
          tokenConfigured: boolean
          connected: boolean
          messageThreadId: number | null
        }
        groups?: AlertsApiGroup[]
        rules: AlertsApiRule[]
        history: AlertsApiHistoryEntry[]
        meta: AlertsMeta
      }>("/api/alerts")
      setTokenConfigured(data.telegram.tokenConfigured)
      setTg({
        token: "",
        chatId: data.telegram.chatId ?? "",
        messageThreadId:
          data.telegram.messageThreadId != null ? String(data.telegram.messageThreadId) : "",
        connected: data.telegram.connected,
      })
      setRules(mapApiRules(data.rules))
      setGroups(mapApiGroups(data.groups ?? []))
      setHistory(mapApiHistory(data.history))
      let mergedMeta: AlertsMeta = data.meta
      try {
        const bgpRows = await apiFetch<BgpSessionListRow[]>("/api/bgp/sessions")
        const bgpPeers = uniqStrings(
          bgpRows.map((s) => `${String(s.serverName ?? "").trim()}: ${String(s.name ?? "").trim()}`.trim()),
        )
        mergedMeta = { ...data.meta, bgpPeers }
      } catch {
        mergedMeta = { ...data.meta, bgpPeers: [] }
      }
      try {
        const [srvList, greRes] = await Promise.all([
          apiFetch<
            Array<{ id: number; name: string; site?: string; country?: string; host?: string }>
          >("/api/servers"),
          apiFetch<{
            tunnels: Array<{
              id?: string
              name: string
              serverId: string
              remoteAddress?: string
              status?: string
              comment?: string
            }>
          }>("/api/filters/gre-tunnels"),
        ])
        const sidToSrv = new Map(
          srvList.map((s) => [
            String(s.id),
            {
              name: String(s.name ?? "").trim() || String(s.id),
              site: String(s.site ?? "").trim(),
              country: String(s.country ?? "").trim(),
              host: String(s.host ?? "").trim(),
            },
          ]),
        )
        const greTunnelDetail: AlertsGreTunnelRow[] = greRes.tunnels.map((t) => {
          const srv = sidToSrv.get(String(t.serverId))
          const sn = srv?.name ?? String(t.serverId)
          const tn = (String(t.name ?? "").trim() || String(t.id ?? "").trim() || "gre").trim()
          const label = `${tn} / ${sn}`.trim()
          const rawSt = (t.status ?? "down").toLowerCase()
          const status: AlertsGreTunnelRow["status"] =
            rawSt === "up" || rawSt === "down" || rawSt === "degraded" ? rawSt : "degraded"
          return {
            targetLabel: label,
            tunnelName: tn,
            routerName: sn,
            country: srv?.country ? srv.country : "—",
            site: srv?.site ? srv.site : "—",
            remoteAddress: (String(t.remoteAddress ?? "").trim() || "—"),
            status,
            comment: (t.comment ?? "").trim() || undefined,
          }
        })
        const greTunnelTargets = uniqStrings(greTunnelDetail.map((r) => r.targetLabel))
        mergedMeta = { ...mergedMeta, greTunnelTargets, greTunnelDetail }
      } catch {
        mergedMeta = { ...mergedMeta, greTunnelTargets: [], greTunnelDetail: [] }
      }
      setMeta(mergedMeta)
    } catch (e) {
      setLoadError(e instanceof Error ? e.message : "Ошибка загрузки")
    } finally {
      setLoadBusy(false)
    }
  }, [apiFetch, isLive])

  useEffect(() => {
    queueMicrotask(() => {
      void load()
    })
  }, [load])

  const flushAlertsConfig = useCallback(async (nextRules: AlertRule[], nextGroups: AlertGroup[]) => {
    setRules(nextRules)
    setGroups(nextGroups)
    if (!isLive) return
    setRulesSaveBusy(true)
    setLoadError(null)
    try {
      const res = await apiFetch<{ rules: AlertsApiRule[]; groups: AlertsApiGroup[] }>("/api/alerts/rules", {
        method: "PUT",
        body: JSON.stringify({
          rules: nextRules.map(toApiRuleBody),
          groups: nextGroups.map(toApiGroupBody),
        }),
      })
      setRules(mapApiRules(res.rules))
      setGroups(mapApiGroups(res.groups ?? []))
    } catch (e) {
      setLoadError(e instanceof Error ? e.message : "Не удалось сохранить правила и группы")
      await load()
    } finally {
      setRulesSaveBusy(false)
    }
  }, [apiFetch, isLive, load])

  const saveTelegram = useCallback(async () => {
    if (!isLive) return
    const threadRaw = tg.messageThreadId.trim()
    let messageThreadId: number | null = null
    if (threadRaw) {
      const n = Number.parseInt(threadRaw, 10)
      if (!Number.isFinite(n) || n < 1) {
        setLoadError("ID темы: укажите положительное целое число или оставьте поле пустым")
        return
      }
      messageThreadId = n
    }
    setTelegramSaveBusy(true)
    setLoadError(null)
    try {
      const res = await apiFetch<{
        telegram: {
          chatId: string
          tokenConfigured: boolean
          connected: boolean
          messageThreadId: number | null
        }
      }>(
        "/api/alerts/telegram",
        {
          method: "PUT",
          body: JSON.stringify({
            chatId: tg.chatId,
            messageThreadId,
            ...(tg.token.trim() ? { token: tg.token.trim() } : {}),
          }),
        },
      )
      setTokenConfigured(res.telegram.tokenConfigured)
      setTg({
        token: "",
        chatId: res.telegram.chatId ?? "",
        messageThreadId:
          res.telegram.messageThreadId != null ? String(res.telegram.messageThreadId) : "",
        connected: res.telegram.connected,
      })
    } catch (e) {
      setLoadError(e instanceof Error ? e.message : "Не удалось сохранить Telegram")
    } finally {
      setTelegramSaveBusy(false)
    }
  }, [apiFetch, isLive, tg.chatId, tg.token, tg.messageThreadId])

  const testTelegram = useCallback(async () => {
    if (!isLive) return
    const threadRaw = tg.messageThreadId.trim()
    const messageThreadId =
      threadRaw ? Number.parseInt(threadRaw, 10) : undefined
    await apiFetch("/api/alerts/telegram/test", {
      method: "POST",
      body: JSON.stringify({
        token: tg.token.trim() || undefined,
        chatId: tg.chatId.trim() || undefined,
        ...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1
          ? { messageThreadId }
          : {}),
      }),
    })
  }, [apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId])

  const testRuleTelegramFromForm = useCallback(
    async (p: RuleTelegramTestPayload) => {
      if (!isLive) return
      const threadRaw = tg.messageThreadId.trim()
      const messageThreadId =
        threadRaw && Number.isFinite(Number.parseInt(threadRaw, 10))
          ? Number.parseInt(threadRaw, 10)
          : undefined
      await apiFetch("/api/alerts/telegram/test", {
        method: "POST",
        body: JSON.stringify({
          token: tg.token.trim() || undefined,
          chatId: p.ruleChatId.trim() || tg.chatId.trim() || undefined,
          ...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1
            ? { messageThreadId }
            : {}),
          rulePreview: {
            name: p.name,
            targets: p.targets,
            conditionLine: p.conditionLine,
            severity: p.severity,
            cooldown: p.cooldown,
          },
        }),
      })
    },
    [apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId],
  )

  const serverNamesForPresets = useMemo(() => {
    if (isLive && meta?.servers?.length) return meta.servers
    return TYPE_TARGETS.server
  }, [isLive, meta])

  const applyPresetAppend = useCallback(
    (presetId: string) => {
      const def = ALERT_PRESETS.find((p) => p.id === presetId)
      if (!def) return
      const built = def.build(serverNamesForPresets)
      if (built.length === 0) return
      const newRows: AlertRule[] = built.map((b) => ({
        ...b,
        id:
          typeof crypto !== "undefined" && "randomUUID" in crypto
            ? crypto.randomUUID()
            : `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
        enabled: true,
        lastFired: null,
      }))
      void flushAlertsConfig([...rules, ...newRows], groups)
    },
    [serverNamesForPresets, rules, groups, flushAlertsConfig],
  )

  const applyPresetReplace = useCallback(
    (presetId: string) => {
      const def = ALERT_PRESETS.find((p) => p.id === presetId)
      if (!def) return
      const built = def.build(serverNamesForPresets)
      const newRows: AlertRule[] = built.map((b) => ({
        ...b,
        id:
          typeof crypto !== "undefined" && "randomUUID" in crypto
            ? crypto.randomUUID()
            : `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
        enabled: true,
        lastFired: null,
      }))
      void flushAlertsConfig(newRows, groups)
    },
    [serverNamesForPresets, groups, flushAlertsConfig],
  )

  const addGroup = useCallback(() => {
    const g: AlertGroup = {
      id:
        typeof crypto !== "undefined" && "randomUUID" in crypto
          ? crypto.randomUUID()
          : `grp-${Date.now()}`,
      name:      "Новая группа",
      combineMode: "any",
      enabled:   true,
      cooldownOverride: null,
    }
    void flushAlertsConfig(rules, [...groups, g])
  }, [rules, groups, flushAlertsConfig])

  const patchGroup = useCallback(
    (id: string, patch: Partial<AlertGroup>) => {
      void flushAlertsConfig(
        rules,
        groups.map((x) => (x.id === id ? { ...x, ...patch } : x)),
      )
    },
    [rules, groups, flushAlertsConfig],
  )

  const removeGroup = useCallback(
    (id: string) => {
      const nextRules = rules.map((r) => (r.groupId === id ? { ...r, groupId: null } : r))
      void flushAlertsConfig(nextRules, groups.filter((g) => g.id !== id))
    },
    [rules, groups, flushAlertsConfig],
  )

  const total    = rules.length
  const active   = rules.filter(r => r.enabled).length
  const critical = rules.filter(r => r.severity === "critical").length
  const warning  = rules.filter(r => r.severity === "warning").length
  const info     = rules.filter(r => r.severity === "info").length

  const filteredRules = useMemo(() => rules.filter(r => {
    if (sevFilter !== "all" && r.severity !== sevFilter) return false
    if (onlyActive && !r.enabled) return false
    const blob =
      `${r.name} ${r.target} ${r.targets.join(" ")} ${r.condition} ${r.conditions.join(" ")}`.toLowerCase()
    if (search && !blob.includes(search.toLowerCase())) return false
    return true
  }), [rules, sevFilter, onlyActive, search])

  const handleToggle = (id: string, enabled: boolean) => {
    const next = rules.map(r => r.id === id ? { ...r, enabled } : r)
    if (isLive) void flushAlertsConfig(next, groups)
    else {
      setRules(next)
    }
  }

  const handleDelete = (id: string) => {
    const next = rules.filter(r => r.id !== id)
    if (isLive) void flushAlertsConfig(next, groups)
    else {
      setRules(next)
    }
  }

  const closeRuleSheet = useCallback(() => {
    setSheetOpen(false)
    setSheetEditId(null)
    setSheetSnap(null)
  }, [])

  const openRuleSheetAdd = useCallback(() => {
    setSheetEditId(null)
    setSheetSnap(null)
    setSheetKey((k) => k + 1)
    setSheetOpen(true)
  }, [])

  const openRuleSheetEdit = useCallback((id: string) => {
    const r = rules.find((x) => x.id === id)
    if (!r) return
    setSheetSnap(ruleToFormSnapshot(r))
    setSheetEditId(id)
    setSheetKey((k) => k + 1)
    setSheetOpen(true)
  }, [rules])

  const handleRuleSheetSave = useCallback(
    (payload: Omit<AlertRule, "id" | "lastFired" | "enabled">, editId: string | null) => {
      if (editId) {
        const cur = rules.find((x) => x.id === editId)
        if (!cur) return
        const next = rules.map((x) =>
          x.id === editId ? { ...x, ...payload, id: editId, enabled: cur.enabled, lastFired: cur.lastFired } : x,
        )
        if (isLive) void flushAlertsConfig(next, groups)
        else {
          setRules(next)
        }
        return
      }
      const row: AlertRule = {
        ...payload,
        id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `r${Date.now()}`,
        enabled: true,
        lastFired: null,
      }
      const next = [...rules, row]
      if (isLive) void flushAlertsConfig(next, groups)
      else {
        setRules(next)
      }
    },
    [rules, groups, isLive, flushAlertsConfig],
  )

  // Summary chip click handler
  const handleChip = (sev: SeverityFilter) =>
    setSevFilter(f => f === sev ? "all" : sev)

  const chipActive = (sev: SeverityFilter) => sevFilter === sev

  return (
    <div className="flex flex-col h-full">
      <PageHeader
        crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
        actions={
          <>
            <Button variant="outline" size="sm" disabled={loadBusy || (isLive && backendStatus === false)}
              onClick={() => { void load() }}>
              {loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
              Обновить
            </Button>
            <Button size="sm" onClick={openRuleSheetAdd} disabled={isLive && backendStatus === false}>
              <PlusIcon className="size-4" />Добавить правило
            </Button>
          </>
        }
      />

      <div className="flex-1 overflow-y-auto p-6">
        <div className="flex flex-col gap-5">

          {isLive && backendStatus === false && (
            <p className="text-xs text-amber-700 dark:text-amber-400 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2">
              Бэкенд недоступен по текущему URL. Проверьте адрес в настройках источника данных или переключитесь в режим демо.
            </p>
          )}

          {!isLive && (
            <p className="text-xs text-muted-foreground rounded-lg border border-border bg-muted/30 px-3 py-2">
              Режим демо: показаны примерные данные без сохранения. Включите «Живые данные» в источнике данных для работы с БД.
            </p>
          )}

          {loadError && (
            <p className="text-xs text-destructive rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2">
              {loadError}
            </p>
          )}

          <Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
            <CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground hover:bg-muted/40 rounded-lg">
              <ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", hintsExpanded && "rotate-180")} />
              Подсказки: формат целей и «восстановился»
            </CollapsibleTrigger>
            <CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2">
              <p>
                Движок читает последние сэмплы из SQLite (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
                срабатывает только если цепочка состояний совпадает с текстом правила.
              </p>
              <ul className="list-disc pl-4 space-y-1.5">
                <li>
                  <span className="font-medium text-foreground">RTT / потери</span> — в объектах укажите ключ пробы
                  как в мониторинге: <code className="rounded bg-muted px-1 font-mono">имя пробы → target</code>.
                </li>
                <li>
                  <span className="font-medium text-foreground">Восстановился</span> — нужна история: после падения
                  в последних сэмплах виден возврат в online (REST и метрики ресурса объединяются). Один текущий
                  online без прошлого offline часто не даёт hit.
                </li>
                <li>
                  <span className="font-medium text-foreground">Перешёл в offline</span> — только на ребре (предыдущий
                  сэмпл был online). Пока сервер остаётся offline, повторные уведомления не шлются.
                </li>
                <li>
                  <span className="font-medium text-foreground">Стабильность / cooldown</span> — задержка перед
                  Telegram и пауза между повторными отправками. В разделе «Сбор данных» в деталях прогона «Оповещения»
                  есть таблица по каждому правилу (hit, стабильность, cooldown, Telegram, причина блока).
                </li>
              </ul>
            </CollapsibleContent>
          </Collapsible>

          {/* ── summary chip bar ── */}
          <div className="flex items-center gap-2 flex-wrap">

            {/* stat chips */}
            {([
              { key: "all",      label: `Всего: ${total}`,          cls: "border-border bg-muted/50 text-foreground" },
              { key: "critical", label: `Критических: ${critical}`, cls: SEVERITY_META.critical.chip },
              { key: "warning",  label: `Предупреждений: ${warning}`, cls: SEVERITY_META.warning.chip },
              { key: "info",     label: `Информационных: ${info}`,  cls: SEVERITY_META.info.chip },
            ] as { key: SeverityFilter; label: string; cls: string }[]).map(({ key, label, cls }) => (
              <button key={key} onClick={() => handleChip(key)}
                className={cn(
                  "text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
                  chipActive(key)
                    ? cls
                    : "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
                )}>
                {label}
              </button>
            ))}

            <div className="w-px h-4 bg-border mx-1" />

            {/* active count chip */}
            <button onClick={() => setOnlyActive(v => !v)}
              className={cn(
                "text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
                onlyActive
                  ? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
                  : "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
              )}>
              Активных: {active}
            </button>

            {/* telegram status */}
            <span className={cn(
              "ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
              tg.connected
                ? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
                : "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
            )}>
              <span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
              {tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
            </span>
          </div>

          {/* ── presets + groups (свёрнуто по умолчанию) ── */}
          <Collapsible open={extrasExpanded} onOpenChange={setExtrasExpanded} className="rounded-lg border border-border bg-card shadow-sm">
            <CollapsibleTrigger
              className={cn(
                "flex w-full items-center gap-3 px-4 py-3 text-left text-sm outline-none transition-colors",
                "hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              )}
            >
              <ChevronDownIcon
                className={cn(
                  "size-4 shrink-0 text-muted-foreground transition-transform duration-200",
                  extrasExpanded && "rotate-180",
                )}
                aria-hidden
              />
              <div className="flex-1 min-w-0">
                <span className="font-medium">Пресеты и группы ANY / ALL</span>
                <span className="hidden sm:block text-[10px] text-muted-foreground font-normal leading-snug mt-0.5">
                  Массовое добавление правил и объединение нескольких правил в одно уведомление
                </span>
              </div>
              <span className="text-[10px] text-muted-foreground shrink-0 tabular-nums">
                {groups.length ? `${groups.length} гр.` : "групп нет"}
              </span>
            </CollapsibleTrigger>
            <CollapsibleContent>
              <div className="border-t border-border/60 px-4 pb-4 pt-3">
                <div className="grid gap-5 lg:grid-cols-2">
                  <OpsPanel title="Пресеты" contentClassName="px-4 pb-4 flex flex-col gap-3">
                      <p className="text-[10px] text-muted-foreground leading-snug">
                        Быстро добавить набор правил из каталога серверов (демо или live meta).
                      </p>
                      {ALERT_PRESETS.map((p) => (
                        <div
                          key={p.id}
                          className="rounded-lg border border-border/80 bg-muted/5 p-3 flex flex-col gap-2"
                        >
                          <p className="text-xs font-semibold">{p.title}</p>
                          <p className="text-[10px] text-muted-foreground leading-snug">{p.description}</p>
                          <div className="flex flex-wrap gap-2">
                            <Button
                              type="button"
                              size="sm"
                              variant="secondary"
                              className="h-7 text-xs"
                              disabled={rulesSaveBusy || (isLive && backendStatus === false)}
                              onClick={() => applyPresetAppend(p.id)}
                            >
                              Добавить к списку
                            </Button>
                            <Button
                              type="button"
                              size="sm"
                              variant="outline"
                              className="h-7 text-xs"
                              disabled={rulesSaveBusy || (isLive && backendStatus === false)}
                              onClick={() => setPendingPresetReplaceId(p.id)}
                            >
                              Заменить все
                            </Button>
                          </div>
                        </div>
                      ))}
                  </OpsPanel>

                  <OpsPanel
                    title="Группы ANY / ALL"
                    headerRight={
                      <Button
                        type="button"
                        size="sm"
                        variant="secondary"
                        className="h-7 text-xs shrink-0"
                        disabled={rulesSaveBusy || (isLive && backendStatus === false)}
                        onClick={addGroup}
                      >
                        <PlusIcon className="size-3.5" />Группа
                      </Button>
                    }
                    contentClassName="px-4 pb-4 flex flex-col gap-3"
                  >
                      {groups.length === 0 ? (
                        <p className="text-[11px] text-muted-foreground">
                          Групп пока нет. Создайте группу и назначьте правилам её в форме правила — движок отправит одно сообщение по логике ANY или ALL.
                        </p>
                      ) : (
                        groups.map((g) => (
                          <div
                            key={g.id}
                            className="rounded-lg border border-border/80 p-3 flex flex-col gap-2"
                          >
                            <div className="flex flex-wrap items-center gap-2">
                              <Input
                                value={g.name}
                                onChange={(e) => patchGroup(g.id, { name: e.target.value })}
                                className="h-8 text-xs flex-1 min-w-[140px]"
                                aria-label="Имя группы"
                              />
                              <Button
                                type="button"
                                size="sm"
                                variant={g.enabled ? "secondary" : "outline"}
                                className="h-7 text-xs"
                                onClick={() => patchGroup(g.id, { enabled: !g.enabled })}
                              >
                                {g.enabled ? "Вкл." : "Выкл."}
                              </Button>
                              <Button
                                type="button"
                                size="sm"
                                variant="ghost"
                                className="h-7 text-xs text-destructive hover:text-destructive"
                                onClick={() => removeGroup(g.id)}
                              >
                                Удалить
                              </Button>
                            </div>
                            <div className="flex flex-wrap gap-2 items-center">
                              <span className="text-[10px] text-muted-foreground shrink-0">Режим</span>
                              <select
                                className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[100px]"
                                value={g.combineMode}
                                onChange={(e) =>
                                  patchGroup(g.id, { combineMode: e.target.value === "all" ? "all" : "any" })
                                }
                              >
                                <option value="any">ANY (или)</option>
                                <option value="all">ALL (и)</option>
                              </select>
                              <span className="text-[10px] text-muted-foreground shrink-0">Cooldown группы</span>
                              <select
                                className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[100px]"
                                value={g.cooldownOverride ?? ""}
                                onChange={(e) => {
                                  const v = e.target.value
                                  patchGroup(g.id, {
                                    cooldownOverride: v === "" ? null : (v as AlertCooldown),
                                  })
                                }}
                              >
                                <option value="">По умолчанию</option>
                                {COOLDOWNS.map((c) => (
                                  <option key={c} value={c}>
                                    {c}
                                  </option>
                                ))}
                              </select>
                            </div>
                          </div>
                        ))
                      )}
                  </OpsPanel>
                </div>
              </div>
            </CollapsibleContent>
          </Collapsible>

          {/* ── main layout ── */}
          <div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">

            {/* ── rules card ── */}
            <OpsPanel
              title={
                <span className="flex items-center gap-2">
                  Правила оповещения
                  {rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
                </span>
              }
              headerRight={
                  <Input
                    placeholder="Поиск…"
                    value={search}
                    onChange={e => setSearch(e.target.value)}
                    autoComplete="off"
                    name="alert-rules-search"
                    className="h-7 text-xs max-w-[200px]"
                  />
              }
              contentClassName="px-0 pb-0 pt-2"
            >
                {filteredRules.length === 0 ? (
                  <div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
                    <BellOffIcon className="size-8 mb-2 opacity-40" />
                    <p className="text-sm">Правила не найдены</p>
                  </div>
                ) : (
                  <div className="divide-y divide-border/60">
                    {filteredRules.map(r => (
                      <AlertRuleRow
                        key={r.id}
                        rule={r}
                        onToggle={handleToggle}
                        onDelete={handleDelete}
                        onEdit={openRuleSheetEdit}
                        interactionsDisabled={rulesSaveBusy}
                      />
                    ))}
                  </div>
                )}

                {/* footer */}
                <div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
                  <span>
                    {filteredRules.length} из {total} правил
                    {onlyActive && " · только активные"}
                    {sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
                  </span>
                  <button
                    type="button"
                    onClick={openRuleSheetAdd}
                    className="flex items-center gap-1 hover:text-foreground transition-colors">
                    <PlusIcon className="size-3" />Добавить правило
                  </button>
                </div>
            </OpsPanel>

            {/* ── right sidebar ── */}
            <div className="flex flex-col gap-5">
              <TelegramCard
                cfg={tg}
                onChange={setTg}
                tokenConfigured={isLive ? tokenConfigured : false}
                liveSaveBusy={telegramSaveBusy}
                onSaveTelegram={isLive ? saveTelegram : undefined}
                onTestTelegram={isLive ? testTelegram : undefined}
              />
              <HistoryCard entries={history} />
            </div>
          </div>
        </div>
      </div>

      <AddRuleSheet
        key={sheetKey}
        open={sheetOpen}
        onClose={closeRuleSheet}
        onSave={handleRuleSheetSave}
        typeTargets={typeTargets}
        isLive={isLive}
        serversCatalog={serversCatalog}
        greTunnelCatalog={greTunnelCatalog}
        groups={groups}
        initialForm={sheetSnap}
        editRuleId={sheetEditId}
        onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
        testTelegramDisabled={isLive && backendStatus === false}
      />

      <AlertDialog
        open={pendingPresetReplaceId != null}
        onOpenChange={(open) => {
          if (!open) setPendingPresetReplaceId(null)
        }}
      >
        <AlertDialogContent size="sm">
          <AlertDialogHeader>
            <AlertDialogTitle>Заменить все правила?</AlertDialogTitle>
            <AlertDialogDescription>
              Текущие правила будут заменены выбранным пресетом. Это нельзя отменить.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Отмена</AlertDialogCancel>
            <AlertDialogAction
              variant="destructive"
              onClick={() => {
                if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId)
                setPendingPresetReplaceId(null)
              }}
            >
              Заменить
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </div>
  )
}
