import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import tw from 'twin.macro';
import http from '@/api/http';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import {
    formatBytes,
    formatPackets,
    formatPercent,
    formatPps,
    formatRelativeTime,
    blockRateColor,
    computeTrend,
    type Trend,
} from './firewallFormat';
import { firewallApiError } from './firewallApiError';
import { unwrapEnvelope } from './firewallApiEnvelope';
import { POLLING } from './firewallConstants';
import type { FirewallRuleRecord } from './firewallTypes';
import { useAbuseDbLookup } from './useAbuseDbLookup';
import AbuseDbLookupOverlay from './abuseDbLookupOverlay';
import type { AbuseDbStatus } from './firewallAbuseDbTypes';

const FirewallChart = lazy(() => import('./firewallChart'));
const FirewallAnalyticsDocsPopup = lazy(() => import('./firewallAnalyticsDocsPopup'));

type Resolution = '1m' | '1h' | '6h' | '24h' | '7d' | '30d';

const RESOLUTIONS: { id: Resolution; label: string; pollMs: number | null }[] = [
    { id: '1m', label: '1m', pollMs: POLLING.DASHBOARD_1M_MS },
    { id: '1h', label: '1h', pollMs: POLLING.DASHBOARD_1H_MS },
    { id: '6h', label: '6h', pollMs: null },
    { id: '24h', label: '24h', pollMs: null },
    { id: '7d', label: '7d', pollMs: null },
    { id: '30d', label: '30d', pollMs: null },
];

const PAGE_SIZE = 5;
const RESOLUTION_STORAGE_KEY = 'fwp-dashboard-resolution';
const REFRESH_INTERVAL_STORAGE_KEY = 'fwp-dashboard-refresh-interval';
const STAT_SPARK_MODE_STORAGE_KEY = 'fwp-stat-spark-mode';

/** Top stat mini-chart display: 0 = filled area, 1 = line only, 2 = hidden. */
type StatSparkDisplayMode = 0 | 1 | 2;

const STAT_SPARK_MODE_LABELS: Record<StatSparkDisplayMode, string> = {
    0: 'Filled mini charts',
    1: 'Line mini charts',
    2: 'Stats only (no mini charts)',
};

/** Bucket duration (seconds) per resolution — mirrors node `RingBufferStore.js` RESOLUTIONS. */
const RES_BUCKET_SEC: Record<Resolution, number> = {
    '1m': 5,
    '1h': 60,
    '6h': 300,
    '24h': 900,
    '7d': 3600,
    '30d': 21600,
};

/** Ring capacity per resolution — keep in sync with node `RingBufferStore.js` RESOLUTIONS. */
const RES_BUCKET_CAPACITY: Record<Resolution, number> = {
    '1m': 12,
    '1h': 60,
    '6h': 72,
    '24h': 96,
    '7d': 168,
    '30d': 120,
};

/** Synthetic bucket for chart padding (full selected window, zeros where the node has no bucket yet). */
function emptyPaddedBucket(windowStart: number): Bucket {
    return {
        windowStart,
        packets_in: '0',
        packets_blocked: '0',
        packets_passed: '0',
        bytes_in: '0',
        bytes_blocked: '0',
        bytes_passed: '0',
        new_connections: 0,
        active_connections: 0,
        unique_sources: 0,
        peak_pps: 0,
        peak_bps: 0,
        per_rule: {},
    };
}

/**
 * Expand series to the full nominal window for the resolution (rolling window ending at "now"),
 * so e.g. 30d shows the whole 30 days with flat zeros before data existed.
 */
function padBucketsToFullWindow(incoming: Bucket[], resolution: Resolution): Bucket[] {
    const bucketMs = RES_BUCKET_SEC[resolution] * 1000;
    const capacity = RES_BUCKET_CAPACITY[resolution];
    if (capacity <= 0 || bucketMs <= 0) return incoming;

    const alignStart = (ts: number) => Math.floor(ts / bucketMs) * bucketMs;
    const endStart = alignStart(Date.now());
    const firstStart = endStart - (capacity - 1) * bucketMs;

    const byStart = new Map<number, Bucket>();
    for (const b of incoming) {
        if (b.windowStart >= firstStart && b.windowStart <= endStart) {
            byStart.set(b.windowStart, b);
        }
    }

    const out: Bucket[] = [];
    for (let t = firstStart; t <= endStart; t += bucketMs) {
        out.push(byStart.get(t) ?? emptyPaddedBucket(t));
    }
    return out;
}

/** Manual poll interval; `auto` uses resolution defaults (1m / 1h only). */
type RefreshIntervalId = 'auto' | '2' | '5' | '10' | '15' | '30' | '45' | '60';

const REFRESH_INTERVAL_OPTIONS: { id: RefreshIntervalId; label: string }[] = [
    { id: 'auto', label: 'Auto' },
    { id: '2', label: '2s' },
    { id: '5', label: '5s' },
    { id: '10', label: '10s' },
    { id: '15', label: '15s' },
    { id: '30', label: '30s' },
    { id: '45', label: '45s' },
    { id: '60', label: '60s' },
];

const REFRESH_ID_TO_MS: Record<Exclude<RefreshIntervalId, 'auto'>, number> = {
    '2': 2000,
    '5': 5000,
    '10': 10000,
    '15': 15000,
    '30': 30000,
    '45': 45000,
    '60': 60000,
};

function loadStoredResolution(): Resolution {
    try {
        const stored = localStorage.getItem(RESOLUTION_STORAGE_KEY);
        if (stored && RESOLUTIONS.some((r) => r.id === stored)) {
            return stored as Resolution;
        }
    } catch {
        // localStorage unavailable (private mode, etc.)
    }
    return '1h';
}

function storeResolution(res: Resolution): void {
    try {
        localStorage.setItem(RESOLUTION_STORAGE_KEY, res);
    } catch {
        // ignore write failures
    }
}

function loadStoredRefreshInterval(): RefreshIntervalId {
    try {
        const stored = localStorage.getItem(REFRESH_INTERVAL_STORAGE_KEY);
        if (stored && REFRESH_INTERVAL_OPTIONS.some((o) => o.id === stored)) {
            return stored as RefreshIntervalId;
        }
    } catch {
        // localStorage unavailable
    }
    return 'auto';
}

function storeRefreshInterval(id: RefreshIntervalId): void {
    try {
        localStorage.setItem(REFRESH_INTERVAL_STORAGE_KEY, id);
    } catch {
        // ignore write failures
    }
}

function loadStoredStatSparkMode(): StatSparkDisplayMode {
    try {
        const stored = localStorage.getItem(STAT_SPARK_MODE_STORAGE_KEY);
        if (stored === '0' || stored === '1' || stored === '2') {
            return Number(stored) as StatSparkDisplayMode;
        }
    } catch {
        // localStorage unavailable
    }
    return 0;
}

function storeStatSparkMode(mode: StatSparkDisplayMode): void {
    try {
        localStorage.setItem(STAT_SPARK_MODE_STORAGE_KEY, String(mode));
    } catch {
        // ignore write failures
    }
}

type Bucket = {
    windowStart: number;
    packets_in: string;
    packets_blocked: string;
    packets_passed: string;
    bytes_in: string;
    bytes_blocked: string;
    bytes_passed: string;
    new_connections: number;
    active_connections: number;
    unique_sources: number;
    peak_pps: number;
    peak_bps: number;
    per_rule: Record<string, { packets_matched: string; bytes_matched: string }>;
    per_proto?: { tcp: string; udp: string; icmp: string };
    per_port?: Record<string, { packets: string; bytes: string }>;
    top_sources?: Source[];
};

type Summary = {
    dataAvailable: boolean;
    reason?: string;
    packets_per_sec: number;
    blocked_per_sec: number;
    passed_per_sec: number;
    bytes_per_sec: number;
    block_rate_pct: number;
    active_connections: number;
    new_connections_per_sec: number;
    unique_sources: number;
    under_attack: boolean;
    mitigation_level: number;
    last_updated_at?: number;
};

type RuleBreakdownRow = {
    rule_key?: string;
    rule_id?: number | null;
    rule_type: string;
    packets_matched: string;
    bytes_matched: string;
    packets_blocked?: string;
    pct_of_total: number;
    pct_of_blocked: number;
    mitigates?: boolean;
};

/** Enabled rules list from metrics node (may include scope/port for disambiguation). */
type ActiveRuleListEntry = {
    rule_id?: number | null;
    rule_type: string;
    scope?: string;
    port?: number | null;
};

type Distribution = {
    ports?: { port: string; packets: string; bytes: string; pct: number }[];
    protocols?: {
        tcp_pct: number;
        udp_pct: number;
        icmp_pct: number;
    };
};

type Source = {
    src: string;
    packets?: number;
    bytes?: number;
    pps?: number;
};

/** API may send legacy `{ ip, count }` from older nodes or persisted buckets. */
function normalizeSourcesList(rows: unknown[]): Source[] {
    const out: Source[] = [];
    for (const r of rows) {
        if (!r || typeof r !== 'object') continue;
        const row = r as Source & { ip?: string; count?: number };
        const src = row.src ?? row.ip;
        if (!src) continue;
        out.push({
            src: String(src),
            packets: Number(row.packets ?? row.count ?? 0) || 0,
            bytes: row.bytes,
            pps: row.pps,
        });
    }
    return out;
}

/** Merge top_sources snapshots from every bucket in the window (same idea as node buildSources). */
function mergeTopSourcesFromBuckets(bkts: Bucket[]): Source[] {
    const merged = new Map<string, number>();
    for (const b of bkts) {
        for (const r of normalizeSourcesList(b.top_sources ?? [])) {
            merged.set(r.src, (merged.get(r.src) ?? 0) + (r.packets ?? 0));
        }
    }
    return [...merged.entries()]
        .map(([src, packets]) => ({ src, packets }))
        .sort((a, b) => (b.packets ?? 0) - (a.packets ?? 0));
}

function resolveBreakdownRuleId(
    row: RuleBreakdownRow | undefined,
    entry: ActiveRuleListEntry,
    panelRules: FirewallRuleRecord[],
): number | null {
    const r0 = row?.rule_id;
    if (r0 != null && Number.isFinite(Number(r0))) return Number(r0);
    const e0 = entry.rule_id;
    if (e0 != null && Number.isFinite(Number(e0))) return Number(e0);
    const scope = entry.scope === 'port' ? 'port' : 'global';
    const port = scope === 'port' && entry.port != null && Number.isFinite(Number(entry.port))
        ? Number(entry.port)
        : null;
    const candidates = panelRules.filter((p) => p.enabled !== false && p.rule_type === entry.rule_type);
    if (candidates.length === 0) return null;
    if (scope === 'port' && port != null) {
        const hit = candidates.find((p) => (p.scope ?? 'global') === 'port' && Number(p.port) === port);
        if (hit) return hit.id;
    }
    const globals = candidates.filter((p) => (p.scope ?? 'global') !== 'port');
    if (globals.length === 1) return globals[0].id;
    const ports = candidates.filter((p) => p.scope === 'port');
    if (ports.length === 1) return ports[0].id;
    return null;
}

type MetricsEnvelopePayload<T> = {
    available: boolean;
    data: T | null;
    error: string | null;
    cached?: boolean;
};

/** Shared surface style for all panels - uses the Nebula theme var when available. */
const surfaceStyle = { background: 'var(--pageSecondary, hsl(209, 18%, 30%))' } as const;
const panelSurface = tw`bg-neutral-700 p-4 rounded shadow-lg`;

// ─── Tiny SVG sparkline ────────────────────────────────────────────────────────
function MiniSparkline({ values, color, fill = false }: { values: number[]; color: string; fill?: boolean }) {
    const W = 100;
    const H = 100;

    if (!values || values.length < 2) {
        return (
            <svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block' }}>
                <line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke={color} strokeWidth="1.5" strokeOpacity={0.25} vectorEffect="non-scaling-stroke" />
            </svg>
        );
    }

    const min = Math.min(...values);
    const max = Math.max(...values);
    const flat = min === max;
    const range = flat ? 1 : max - min;
    const toX = (i: number) => (i / (values.length - 1)) * W;
    const toY = (v: number) => {
        if (flat) return H / 2;
        // Leave headroom at top so peaks don't clip against the chart box edge
        return H - 6 - ((v - min) / range) * (H - 14);
    };
    const pts = values.map((v, i) => `${toX(i).toFixed(1)},${toY(v).toFixed(1)}`);
    const poly = pts.join(' ');
    const fillPath = fill ? `M 0,${H} L ${pts.join(' L ')} L ${W},${H} Z` : '';

    return (
        <svg
            width="100%"
            height="100%"
            viewBox={`0 0 ${W} ${H}`}
            preserveAspectRatio="none"
            style={{ display: 'block' }}
        >
            {fill && !flat && <path d={fillPath} fill={color} fillOpacity={0.18} />}
            <polyline
                points={poly}
                fill="none"
                stroke={color}
                strokeWidth="2"
                strokeLinejoin="round"
                strokeLinecap="round"
                vectorEffect="non-scaling-stroke"
            />
        </svg>
    );
}

/** Top-right inside sparkline panel: green ↑ = metric rose in 2nd half vs 1st, red ↓ = fell. */
function SparklineTrendCorner({ trend }: { trend: Trend }) {
    if (trend.direction === 'flat') {
        return <span css={tw`text-[10px] font-medium leading-none text-neutral-500 select-none`}>Stable</span>;
    }
    const up = trend.direction === 'up';
    const color = up ? '#34d399' : '#f87171';
    const arrow = up ? '↑' : '↓';
    return (
        <span css={tw`text-[10px] font-semibold leading-none whitespace-nowrap select-none`} style={{ color }}>
            {arrow} {trend.pct.toFixed(1)}%
        </span>
    );
}

/** Sparkline panel: fixed width (extends left from right edge), capped height. */
const STAT_SPARK_WIDTH = 200;
const STAT_CARD_MIN_H = 100;
const STAT_SPARK_PANEL_H = 100;
const STAT_SPARK_MIN_CHART_H = 48;

/** Right-side sparkline slot: trend label in top row so the chart never overlaps it. */
function StatSparkline({
    values,
    color,
    trend,
    fill,
}: {
    values: number[];
    color: string;
    trend: Trend;
    fill: boolean;
}) {
    return (
        <div
            css={tw`flex items-center justify-end flex-shrink-0`}
            style={{ width: STAT_SPARK_WIDTH, minWidth: STAT_SPARK_WIDTH }}
        >
            <div
                css={tw`w-full rounded flex flex-col min-h-0`}
                style={{
                    background: 'var(--pageBackground, hsl(210, 24%, 14%))',
                    border: '1px solid rgba(255,255,255,0.04)',
                    padding: '5px 8px',
                    gap: 3,
                    height: STAT_SPARK_PANEL_H,
                }}
            >
                <div css={tw`flex justify-end items-start flex-shrink-0`}>
                    <SparklineTrendCorner trend={trend} />
                </div>
                <div css={tw`w-full flex-1 min-w-0 min-h-0`} style={{ minHeight: STAT_SPARK_MIN_CHART_H }}>
                    <MiniSparkline values={values} color={color} fill={fill} />
                </div>
            </div>
        </div>
    );
}

function StatTitleIcon({ children }: { children: React.ReactNode }) {
    return (
        <span
            css={tw`inline-flex items-center justify-center flex-shrink-0 text-current [&>svg]:block [&>svg]:w-[0.9em] [&>svg]:h-[0.9em] [&>svg]:relative [&>svg]:-top-px`}
            aria-hidden
        >
            {children}
        </span>
    );
}

function IcoChartArea() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M3 3v16a2 2 0 0 0 2 2h16" />
            <path d="M7 11.207a.5.5 0 0 1 .146-.353l2-2a.5.5 0 0 1 .708 0l3.292 3.292a.5.5 0 0 0 .708 0l4.292-4.292a.5.5 0 0 1 .854.353V16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1z" />
        </svg>
    );
}

function IcoArrowDownUp() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="m3 16 4 4 4-4" />
            <path d="M7 20V4" />
            <path d="m21 8-4-4-4 4" />
            <path d="M17 4v16" />
        </svg>
    );
}
function IcoShieldX() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
            <path d="m14.5 9.5-5 5" />
            <path d="m9.5 9.5 5 5" />
        </svg>
    );
}
function IcoShieldCheck() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
            <path d="m9 12 2 2 4-4" />
        </svg>
    );
}
function IcoArrowBigUp() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M9 19a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6a1 1 0 0 1 1-1h3.293a.707.707 0 0 0 .5-1.207l-7.086-7.086a1 1 0 0 0-1.414 0l-7.086 7.086a.707.707 0 0 0 .5 1.207H8a1 1 0 0 1 1 1z" />
        </svg>
    );
}
function IcoCirclePlus() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <circle cx="12" cy="12" r="10" />
            <path d="M8 12h8" />
            <path d="M12 8v8" />
        </svg>
    );
}
function IcoMousePointerClick() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M14 4.1 12 6" />
            <path d="m5.1 8-2.9-.8" />
            <path d="m6 12-1.9 2" />
            <path d="M7.2 2.2 8 5.1" />
            <path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />
        </svg>
    );
}
function IcoBrickWallFire() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M16 3v2.107" />
            <path d="M17 9c1 3 2.5 3.5 3.5 4.5A5 5 0 0 1 22 17a5 5 0 0 1-10 0c0-.3 0-.6.1-.9a2 2 0 1 0 3.3-2C13 11.5 16 9 17 9" />
            <path d="M21 8.274V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.938" />
            <path d="M3 15h5.253" />
            <path d="M3 9h8.228" />
            <path d="M8 15v6" />
            <path d="M8 3v6" />
        </svg>
    );
}
function IcoActivity() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
        </svg>
    );
}
function IcoStretchHorizontal() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <rect width="20" height="6" x="2" y="4" rx="2" />
            <rect width="20" height="6" x="2" y="14" rx="2" />
        </svg>
    );
}
function IcoChartNoAxesCombined() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 16v5" />
            <path d="M16 14.639V21" />
            <path d="M20 10.656V21" />
            <path d="m22 3-8.646 8.646a.5.5 0 0 1-.708 0L9.354 8.354a.5.5 0 0 0-.707 0L2 15" />
            <path d="M4 18.463V21" />
            <path d="M8 14.656V21" />
        </svg>
    );
}
function IcoChartNoAxesGantt() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M6 5h12" />
            <path d="M4 12h10" />
            <path d="M12 19h8" />
        </svg>
    );
}
function IcoWifi() {
    return (
        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 20h.01" />
            <path d="M2 8.82a15 15 0 0 1 20 0" />
            <path d="M5 12.859a10 10 0 0 1 14 0" />
            <path d="M8.5 16.429a5 5 0 0 1 7 0" />
        </svg>
    );
}
function IcoRefreshCw() {
    return (
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
            <path d="M21 3v5h-5" />
            <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
            <path d="M8 16H3v5" />
        </svg>
    );
}

// ─── Pagination arrows ────────────────────────────────────────────────────────
function PaginationBar({
    page,
    totalPages,
    onPrev,
    onNext,
}: {
    page: number;
    totalPages: number;
    onPrev: () => void;
    onNext: () => void;
}) {
    if (totalPages <= 1) return null;
    return (
        <div css={tw`flex items-center justify-between mt-2 pt-1`} style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
            <button
                type="button"
                disabled={page === 0}
                onClick={onPrev}
                css={tw`p-1 text-neutral-400 disabled:opacity-25 hover:text-neutral-200 transition-colors`}
                aria-label="Previous page"
            >
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                    <path d="m15 18-6-6 6-6" />
                </svg>
            </button>
            <span css={tw`text-xs text-neutral-500`}>{page + 1} / {totalPages}</span>
            <button
                type="button"
                disabled={page >= totalPages - 1}
                onClick={onNext}
                css={tw`p-1 text-neutral-400 disabled:opacity-25 hover:text-neutral-200 transition-colors`}
                aria-label="Next page"
            >
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                    <path d="m9 18 6-6-6-6" />
                </svg>
            </button>
        </div>
    );
}

type Props = {
    base: string;
    onError: (msg: string) => void;
    canManage?: boolean;
    /** When true, top source IPs can be looked up via AbuseIPDB. */
    canAbuseDb?: boolean;
    onAbuseDbSuccess?: (msg: string) => void;
};

export default function FirewallDashboard({
    base,
    onError,
    canManage = true,
    canAbuseDb = false,
    onAbuseDbSuccess,
}: Props) {
    const [resolution, setResolution] = useState<Resolution>(loadStoredResolution);
    const [summary, setSummary] = useState<Summary | null>(null);
    const [buckets, setBuckets] = useState<Bucket[]>([]);
    const [rules, setRules] = useState<RuleBreakdownRow[]>([]);
    const [activeRuleTypes, setActiveRuleTypes] = useState<string[]>([]);
    const [activeRuleEntries, setActiveRuleEntries] = useState<ActiveRuleListEntry[]>([]);
    const [panelRulesForIds, setPanelRulesForIds] = useState<FirewallRuleRecord[]>([]);
    const [sources, setSources] = useState<Source[]>([]);
    const [loadingSeries, setLoadingSeries] = useState(false);
    const [lastSeriesFetchedAt, setLastSeriesFetchedAt] = useState<number | null>(null);
    const [relativeNow, setRelativeNow] = useState(() => Date.now());
    const [rulesPage, setRulesPage] = useState(0);
    const [sourcesPage, setSourcesPage] = useState(0);
    const [showAnalyticsDocs, setShowAnalyticsDocs] = useState(false);
    const [statSparkMode, setStatSparkMode] = useState<StatSparkDisplayMode>(loadStoredStatSparkMode);
    const [distribution, setDistribution] = useState<Distribution | null>(null);
    const [resetting, setResetting] = useState(false);
    const [refreshInterval, setRefreshInterval] = useState<RefreshIntervalId>(loadStoredRefreshInterval);
    /** Cumulative degrees so each click animates a full 360° (browsers normalize multiples of 360). */
    const [refreshSpinDeg, setRefreshSpinDeg] = useState(0);
    const tabVisibleRef = useRef(true);
    const [abuseDbVerified, setAbuseDbVerified] = useState(false);

    const abuseDbLookup = useAbuseDbLookup(base, onError, onAbuseDbSuccess);

    useEffect(() => {
        if (!canAbuseDb) {
            setAbuseDbVerified(false);
            return;
        }
        http.get(`${base}/abusedb/status`)
            .then((res) => {
                const status = unwrapEnvelope<AbuseDbStatus>(res.data);
                setAbuseDbVerified(!!status?.verified);
            })
            .catch(() => setAbuseDbVerified(false));
    }, [base, canAbuseDb]);

    const lookupSourceIp = useCallback((ip: string) => {
        if (!abuseDbVerified) {
            onError('Configure an AbuseIPDB API key in the AbuseDB tab first.');
            return;
        }
        abuseDbLookup.lookup(ip);
    }, [abuseDbLookup, abuseDbVerified, onError]);

    const fetchSummary = useCallback(async () => {
        try {
            const response = await http.get(`${base}/metrics/summary`);
            const payload = unwrapEnvelope<MetricsEnvelopePayload<Summary>>(response.data);
            if (payload?.available && payload.data) {
                setSummary(payload.data);
            } else if (!payload?.available) {
                setSummary({
                    dataAvailable: false,
                    reason: payload?.error ?? 'unavailable',
                    packets_per_sec: 0,
                    blocked_per_sec: 0,
                    passed_per_sec: 0,
                    bytes_per_sec: 0,
                    block_rate_pct: 0,
                    active_connections: 0,
                    new_connections_per_sec: 0,
                    unique_sources: 0,
                    under_attack: false,
                    mitigation_level: 0,
                });
            }
        } catch (e) {
            onError(firewallApiError(e));
        }
    }, [base, onError]);

    const fetchSeries = useCallback(async (res: Resolution) => {
        setLoadingSeries(true);
        try {
            const [tsResp, rulesResp, sourcesResp, panelRulesResp] = await Promise.all([
                http.get(`${base}/metrics/timeseries?resolution=${res}`),
                http.get(`${base}/metrics/rules?resolution=${res}`),
                http.get(`${base}/metrics/sources?resolution=${res}&limit=20`).catch(() => null),
                http.get(`${base}/rules`).catch(() => null),
            ]);

            const timeseries = unwrapEnvelope<MetricsEnvelopePayload<{
                buckets?: Bucket[];
                distribution?: Distribution;
            }>>(tsResp.data);
            const ruleBreakdown = unwrapEnvelope<MetricsEnvelopePayload<{
                rules?: RuleBreakdownRow[];
                active_rule_types?: string[];
                active_rules?: ActiveRuleListEntry[];
            }>>(rulesResp.data);
            const sourcesData = sourcesResp
                ? unwrapEnvelope<MetricsEnvelopePayload<{ sources?: Source[] }>>(sourcesResp.data)
                : null;

            const rawBuckets: Bucket[] = timeseries?.data?.buckets ?? [];
            const bkts = padBucketsToFullWindow(rawBuckets, res);
            setBuckets(bkts);
            setDistribution(timeseries?.data?.distribution ?? null);
            setRules(ruleBreakdown?.data?.rules ?? []);
            setActiveRuleTypes(ruleBreakdown?.data?.active_rule_types ?? []);
            setActiveRuleEntries(ruleBreakdown?.data?.active_rules ?? []);

            if (panelRulesResp) {
                const pr = unwrapEnvelope<FirewallRuleRecord[]>(panelRulesResp.data);
                if (Array.isArray(pr)) setPanelRulesForIds(pr);
            }

            // Sources: merge dedicated /metrics/sources with summed top_sources across all buckets.
            const apiSources = normalizeSourcesList(sourcesData?.data?.sources ?? []);
            const fromBuckets = mergeTopSourcesFromBuckets(rawBuckets);
            const mergedBySrc = new Map<string, Source>();
            for (const s of [...apiSources, ...fromBuckets]) {
                const prev = mergedBySrc.get(s.src);
                mergedBySrc.set(s.src, {
                    src: s.src,
                    packets: (prev?.packets ?? 0) + (s.packets ?? 0),
                    bytes: s.bytes ?? prev?.bytes,
                    pps: s.pps ?? prev?.pps,
                });
            }
            const mergedSources = [...mergedBySrc.values()]
                .sort((a, b) => (b.packets ?? 0) - (a.packets ?? 0))
                .slice(0, 20);
            setSources(mergedSources);

            setLastSeriesFetchedAt(Date.now());
        } catch (e) {
            onError(firewallApiError(e));
        } finally {
            setLoadingSeries(false);
        }
    }, [base, onError]);

    const handleManualRefresh = useCallback(async () => {
        setRefreshSpinDeg((d) => d + 360);
        setRulesPage(0);
        setSourcesPage(0);
        await Promise.all([fetchSeries(resolution), fetchSummary()]);
    }, [resolution, fetchSeries, fetchSummary]);

    useEffect(() => {
        fetchSummary();
        setRulesPage(0);
        setSourcesPage(0);
        fetchSeries(resolution);
    }, [resolution, fetchSummary, fetchSeries]);

    useEffect(() => {
        const cfg = RESOLUTIONS.find((r) => r.id === resolution);
        const autoPollMs = cfg?.pollMs ?? null;
        const intervalMs =
            refreshInterval === 'auto' ? autoPollMs : REFRESH_ID_TO_MS[refreshInterval];
        if (!intervalMs) return;

        const tick = () => {
            if (!tabVisibleRef.current) return;
            fetchSummary();
            if (refreshInterval === 'auto') {
                if (resolution === '1m') fetchSeries('1m');
            } else {
                fetchSeries(resolution);
            }
        };
        const handle = setInterval(tick, intervalMs);
        return () => clearInterval(handle);
    }, [resolution, refreshInterval, fetchSummary, fetchSeries]);

    useEffect(() => {
        const onChange = () => { tabVisibleRef.current = !document.hidden; };
        document.addEventListener('visibilitychange', onChange);
        return () => document.removeEventListener('visibilitychange', onChange);
    }, []);

    // Re-render "Updated X ago" every second - on 1m view series refresh resets the
    // timestamp every 5s so without this tick the label stays stuck at "0s ago".
    useEffect(() => {
        const handle = setInterval(() => setRelativeNow(Date.now()), 1000);
        return () => clearInterval(handle);
    }, []);

    const selectResolution = useCallback((res: Resolution) => {
        setResolution(res);
        storeResolution(res);
    }, []);

    const cycleStatSparkMode = useCallback(() => {
        setStatSparkMode((prev) => {
            const next = ((prev + 1) % 3) as StatSparkDisplayMode;
            storeStatSparkMode(next);
            return next;
        });
    }, []);

    const resetAnalytics = useCallback(async () => {
        if (!window.confirm(
            'Reset all analytics for this server? Historical charts and stats will be cleared. Firewall rules are not removed.',
        )) {
            return;
        }
        setResetting(true);
        try {
            await http.post(`${base}/metrics/reset`);
            setBuckets([]);
            setRules([]);
            setSources([]);
            setRulesPage(0);
            setSourcesPage(0);
            setDistribution(null);
            setPanelRulesForIds([]);
            setLastSeriesFetchedAt(null);
            await fetchSummary();
            await fetchSeries(resolution);
        } catch (e) {
            onError(firewallApiError(e));
        } finally {
            setResetting(false);
        }
    }, [base, onError, fetchSummary, fetchSeries, resolution]);

    // ─── Aggregated totals ─────────────────────────────────────────────────────
    const totals = useMemo(() => {
        const sum = (k: keyof Bucket) => buckets.reduce((acc, b) => acc + Number(b[k] ?? 0), 0);
        const totalPackets = sum('packets_in');
        const totalBlocked = sum('packets_blocked');
        const totalPassed = sum('packets_passed');
        const totalBytes = sum('bytes_in');
        const totalBlockedBytes = sum('bytes_blocked');
        const totalPassedBytes = sum('bytes_passed');
        const totalNewConns = sum('new_connections');
        const peakPps = buckets.reduce((m, b) => Math.max(m, b.peak_pps ?? 0), 0);
        const peakAt = buckets.reduce<number | null>((acc, b) => {
            if ((b.peak_pps ?? 0) === peakPps && peakPps > 0) return b.windowStart;
            return acc;
        }, null);
        const avgBlockRate = totalPackets > 0 ? (totalBlocked / totalPackets) * 100 : 0;
        const avgPassRate = totalPackets > 0 ? (totalPassed / totalPackets) * 100 : 0;
        const avgPacketSize = totalPackets > 0 ? totalBytes / totalPackets : 0;
        const uniqueSources = buckets.reduce((m, b) => Math.max(m, b.unique_sources ?? 0), 0);
        return {
            totalPackets, totalBlocked, totalPassed,
            totalBytes, totalBlockedBytes, totalPassedBytes,
            totalNewConns, peakPps, peakAt, avgBlockRate, avgPassRate, avgPacketSize, uniqueSources,
        };
    }, [buckets]);

    // ─── Trend calculation ─────────────────────────────────────────────────────
    const trends = useMemo(() => {
        const flat = { direction: 'flat' as const, pct: 0 };
        if (buckets.length < 4) return { traffic: flat, blockRate: flat, passed: flat };
        const mid = Math.floor(buckets.length / 2);
        const prevTotal = buckets.slice(0, mid).reduce((a, b) => a + Number(b.packets_in), 0);
        const currTotal = buckets.slice(mid).reduce((a, b) => a + Number(b.packets_in), 0);
        const prevPassed = buckets.slice(0, mid).reduce((a, b) => a + Number(b.packets_passed), 0);
        const currPassed = buckets.slice(mid).reduce((a, b) => a + Number(b.packets_passed), 0);
        const prevBlocked = buckets.slice(0, mid).reduce((a, b) => a + Number(b.packets_blocked), 0);
        const currBlocked = buckets.slice(mid).reduce((a, b) => a + Number(b.packets_blocked), 0);
        const prevRate = prevTotal > 0 ? (prevBlocked / prevTotal) * 100 : 0;
        const currRate = currTotal > 0 ? (currBlocked / currTotal) * 100 : 0;
        return {
            traffic: computeTrend(currTotal, prevTotal),
            blockRate: computeTrend(currRate, prevRate),
            passed: computeTrend(currPassed, prevPassed),
        };
    }, [buckets]);

    // ─── Sparkline data ────────────────────────────────────────────────────────
    const sparkTraffic = useMemo(() => buckets.map(b => Number(b.packets_in)), [buckets]);
    const sparkBlocked = useMemo(() => buckets.map(b => Number(b.packets_blocked)), [buckets]);
    const sparkPassed = useMemo(() => buckets.map(b => Number(b.packets_passed)), [buckets]);

    // ─── Protocol distribution ─────────────────────────────────────────────────
    const protoStats = useMemo(() => {
        if (distribution?.protocols) {
            return {
                tcp: distribution.protocols.tcp_pct ?? 0,
                udp: distribution.protocols.udp_pct ?? 0,
                icmp: distribution.protocols.icmp_pct ?? 0,
            };
        }
        let tcp = 0, udp = 0, icmp = 0;
        for (const b of buckets) {
            tcp += Number(b.per_proto?.tcp ?? 0);
            udp += Number(b.per_proto?.udp ?? 0);
            icmp += Number(b.per_proto?.icmp ?? 0);
        }
        const total = tcp + udp + icmp || 1;
        return { tcp: (tcp / total) * 100, udp: (udp / total) * 100, icmp: (icmp / total) * 100 };
    }, [buckets, distribution]);

    const portStats = useMemo(() => {
        if (distribution?.ports && distribution.ports.length > 0) {
            return distribution.ports.map((p) => ({
                port: p.port,
                packets: Number(p.packets),
                pct: p.pct,
            }));
        }
        const acc: Record<string, number> = {};
        for (const b of buckets) {
            if (b.per_port) {
                for (const [port, val] of Object.entries(b.per_port)) {
                    const row = val as { packets: string; bytes: string };
                    acc[port] = (acc[port] ?? 0) + Number(row.packets);
                }
            }
        }
        const total = Object.values(acc).reduce((s, v) => s + v, 0) || 1;
        return Object.entries(acc)
            .map(([port, packets]) => ({ port, packets, pct: (packets / total) * 100 }))
            .sort((a, b) => b.packets - a.packets);
    }, [buckets, distribution]);

    // ─── All rules (stats + configured-but-idle) sorted by activity ───────────
    const allRules = useMemo(() => {
        const statsMap = new Map<string, RuleBreakdownRow>();
        for (const r of rules) {
            const k = r.rule_key ?? (r.rule_id != null && r.rule_id !== undefined
                ? `id:${r.rule_id}/${r.rule_type}`
                : r.rule_type);
            statsMap.set(k, { ...r, rule_key: r.rule_key ?? k });
        }

        type RowEntry = {
            rule_key: string;
            rule_type: string;
            rule_id?: number | null;
            entry: ActiveRuleListEntry;
        };

        const entries: RowEntry[] =
            activeRuleEntries.length > 0
                ? activeRuleEntries.map((a) => ({
                    rule_key: a.rule_id != null && Number.isFinite(Number(a.rule_id))
                        ? `id:${a.rule_id}/${a.rule_type}`
                        : a.rule_type,
                    rule_type: a.rule_type,
                    rule_id: a.rule_id,
                    entry: a,
                }))
                : activeRuleTypes.map((rt) => ({
                    rule_key: rt,
                    rule_type: rt,
                    rule_id: null,
                    entry: { rule_type: rt, rule_id: null },
                }));

        return entries
            .map((e) => {
                let row = statsMap.get(e.rule_key);
                if (!row) row = statsMap.get(e.rule_type);
                const resolvedId = resolveBreakdownRuleId(row, e.entry, panelRulesForIds);
                if (row) {
                    return {
                        ...row,
                        rule_type: e.rule_type,
                        rule_id: resolvedId ?? row.rule_id ?? (e.rule_id != null ? Number(e.rule_id) : null),
                        rule_key: row.rule_key ?? e.rule_key,
                    };
                }
                return {
                    rule_key: e.rule_key,
                    rule_type: e.rule_type,
                    rule_id: resolvedId ?? (e.rule_id != null ? Number(e.rule_id) : null),
                    packets_matched: '0',
                    bytes_matched: '0',
                    packets_blocked: '0',
                    pct_of_total: 0,
                    pct_of_blocked: 0,
                    mitigates: false,
                };
            })
            .sort((a, b) => {
                const ap = Number(a.packets_matched);
                const bp = Number(b.packets_matched);
                if (bp !== ap) return bp - ap;
                const ida = a.rule_id ?? 0;
                const idb = b.rule_id ?? 0;
                if (ida !== idb) return ida - idb;
                return a.rule_type.localeCompare(b.rule_type);
            });
    }, [rules, activeRuleEntries, activeRuleTypes, panelRulesForIds]);

    /** Summary row for header: use API summary, or derive from latest buckets if summary is "no data" but series exists. */
    const headerStats = useMemo((): Summary | null => {
        if (!summary) return null;
        if (summary.dataAvailable) return summary;
        if (!buckets.length) return null;
        const sec = RES_BUCKET_SEC[resolution] ?? 60;
        const last = buckets[buckets.length - 1];
        const tail = buckets.slice(-12);
        const pktIn = Number(BigInt(last.packets_in || '0') / BigInt(sec)) || 0;
        const pktBlocked = Number(BigInt(last.packets_blocked || '0') / BigInt(sec)) || 0;
        const pktPassed = Number(BigInt(last.packets_passed || '0') / BigInt(sec)) || 0;
        const bps = Number(BigInt(last.bytes_in || '0') / BigInt(sec)) || 0;
        const totalForRate = Number(BigInt(last.packets_in || '0'));
        const blockedForRate = Number(BigInt(last.packets_blocked || '0'));
        const activeConnections = Math.max(0, ...tail.map((b) => Number(b.active_connections ?? 0)));
        const uniqueSources = Math.max(0, ...tail.map((b) => Number(b.unique_sources ?? 0)));
        return {
            ...summary,
            dataAvailable: true,
            packets_per_sec: pktIn,
            blocked_per_sec: pktBlocked,
            passed_per_sec: pktPassed,
            bytes_per_sec: bps,
            block_rate_pct: totalForRate > 0 ? (blockedForRate / totalForRate) * 100 : 0,
            active_connections: activeConnections,
            new_connections_per_sec: (last.new_connections ?? 0) / sec,
            unique_sources: uniqueSources,
            last_updated_at: last.windowStart + sec * 1000,
        };
    }, [summary, buckets, resolution]);

    const rulePageTotal = Math.ceil(allRules.length / PAGE_SIZE) || 1;
    const sourcePageTotal = Math.ceil(sources.length / PAGE_SIZE) || 1;
    const visibleRules = allRules.slice(rulesPage * PAGE_SIZE, (rulesPage + 1) * PAGE_SIZE);
    const visibleSources = sources.slice(sourcesPage * PAGE_SIZE, (sourcesPage + 1) * PAGE_SIZE);

    const liveBlockColor = headerStats ? blockRateColor(headerStats.block_rate_pct) : undefined;
    const statusColor = !headerStats ? undefined : headerStats.under_attack ? '#f87171' : '#34d399';
    const statusText = !headerStats ? 'NO DATA' : headerStats.under_attack ? `MITIGATION L${headerStats.mitigation_level}` : 'ACTIVE';
    const showStatSpark = statSparkMode !== 2;
    const statSparkFill = statSparkMode === 0;
    const statCardCss = [
        tw`bg-neutral-700 p-4 rounded shadow-lg min-w-0`,
        showStatSpark ? tw`flex flex-row gap-3 items-stretch` : tw`flex flex-col items-center justify-center`,
    ];
    const statBodyCss = showStatSpark
        ? tw`flex flex-col gap-1.5 flex-1 min-w-0 justify-center`
        : tw`flex flex-col gap-1.5 items-center text-center w-full justify-center`;
    const statTitleCss = [
        tw`text-sm font-medium text-neutral-300 leading-none inline-flex items-center gap-1`,
        !showStatSpark && tw`justify-center`,
    ];
    const statMetricRowCss = [tw`flex items-baseline gap-2`, !showStatSpark && tw`justify-center`];

    return (
        <div>
            {/* ── Header bar ─────────────────────────────────────────────────── */}
            <div css={[panelSurface, tw`flex flex-wrap gap-3 mb-4 items-center`]} style={surfaceStyle}>
                <span
                    css={tw`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium text-neutral-200`}
                    style={{ background: 'var(--pageBackground, hsl(210, 24%, 16%))' }}
                >
                    {statusText === 'ACTIVE' && (
                        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                            <path d="M18 6 7 17l-5-5" /><path d="m22 10-7.5 7.5L13 16" />
                        </svg>
                    )}
                    {statusText !== 'ACTIVE' && statusColor === '#f87171' && (
                        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                            <path d="M10.114 4.462A14.5 14.5 0 0 1 12 2a10 10 0 0 1 9.313 13.643" />
                            <path d="M15.557 15.556A14.5 14.5 0 0 1 12 22 10 10 0 0 1 4.929 4.929" />
                            <path d="M15.892 10.234A14.5 14.5 0 0 0 12 2a10 10 0 0 0-3.643.687" />
                            <path d="M17.656 12H22" />
                            <path d="M19.071 19.071A10 10 0 0 1 12 22 14.5 14.5 0 0 1 8.44 8.45" />
                            <path d="M2 12h10" /><path d="m2 2 20 20" />
                        </svg>
                    )}
                    {!statusColor && <span css={tw`inline-block w-2 h-2 rounded-full bg-neutral-500`} />}
                    {statusText}
                </span>

                <button
                    type="button"
                    onClick={() => setShowAnalyticsDocs(true)}
                    css={tw`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium text-neutral-200 hover:text-neutral-100 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/60`}
                    style={{ background: 'var(--pageBackground, hsl(210, 24%, 16%))' }}
                    aria-label="Open analytics guide"
                >
                    <StatTitleIcon children={(
                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                            <circle cx="12" cy="12" r="10" />
                            <path d="M12 16v-4" />
                            <path d="M12 8h.01" />
                        </svg>
                    )} />
                    Analytics guide
                </button>

                <button
                    type="button"
                    onClick={cycleStatSparkMode}
                    css={tw`inline-flex items-center text-sm text-neutral-400 hover:text-neutral-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/60 bg-transparent border-0 p-0 flex-shrink-0`}
                    style={{ opacity: statSparkMode === 0 ? 1 : 0.92 }}
                    title={STAT_SPARK_MODE_LABELS[statSparkMode]}
                    aria-label={`Stat chart display: ${STAT_SPARK_MODE_LABELS[statSparkMode]}`}
                >
                    <StatTitleIcon children={<IcoChartArea />} />
                </button>

                <div css={tw`flex-1`} />

                {/* Live stats pill (API summary or derived from visible time-series buckets) */}
                {headerStats && (
                    <div
                        css={tw`inline-flex items-center gap-3 px-3 py-2 rounded-full text-xs`}
                        style={{ background: 'var(--pageSecondary, rgba(0,0,0,0.35))', border: '1px solid var(--pageSecondaryHover, rgba(255,255,255,0.06))' }}
                    >
                        <span css={tw`flex items-center gap-1 text-neutral-400`}>
                            <span css={tw`inline-block w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse`} />
                            LIVE
                        </span>
                        <div css={tw`w-px h-3.5`} style={{ background: 'rgba(255,255,255,0.08)' }} />
                        <div css={tw`flex flex-col items-center`}>
                            <span css={tw`text-neutral-500`}>Packets/sec</span>
                            <span css={tw`font-mono text-neutral-200 font-medium`}>{formatPps(headerStats.packets_per_sec)}</span>
                        </div>
                        <div css={tw`w-px h-3.5`} style={{ background: 'rgba(255,255,255,0.08)' }} />
                        <div css={tw`flex flex-col items-center`}>
                            <span css={tw`text-neutral-500`}>Blocked/sec</span>
                            <span css={tw`font-mono font-medium`} style={{ color: liveBlockColor ?? '#e5e7eb' }}>
                                {formatPps(headerStats.blocked_per_sec)}
                            </span>
                        </div>
                        <div css={tw`w-px h-3.5`} style={{ background: 'rgba(255,255,255,0.08)' }} />
                        <div css={tw`flex flex-col items-center`}>
                            <span css={tw`text-neutral-500`}>Block rate</span>
                            <span css={tw`font-mono font-medium`} style={{ color: liveBlockColor ?? '#e5e7eb' }}>
                                {formatPercent(headerStats.block_rate_pct)}
                            </span>
                        </div>
                        <div css={tw`w-px h-3.5`} style={{ background: 'rgba(255,255,255,0.08)' }} />
                        <div css={tw`flex flex-col items-center`}>
                            <span css={tw`text-neutral-500`}>Connections</span>
                            <span css={tw`font-mono text-neutral-200 font-medium`}>{headerStats.active_connections}</span>
                        </div>
                    </div>
                )}
            </div>

            {/* ── No-data warning ────────────────────────────────────────────── */}
            {!summary?.dataAvailable && buckets.length === 0 && (
                <div css={[panelSurface, tw`mb-4 text-sm text-neutral-300`]} style={surfaceStyle}>
                    <strong css={tw`text-neutral-100`}>No analytics data yet.</strong> Make sure the firewall
                    node service is reachable and rules have been applied. New servers may take up to 30s
                    after the first apply before counters start populating.
                    {summary?.reason && <p css={tw`text-xs text-neutral-400 mt-1`}>{summary.reason}</p>}
                </div>
            )}

            {/* ── Resolution selector + refresh ─────────────────────────────── */}
            <div css={tw`relative flex gap-1 mb-4 flex-wrap items-center min-h-[2.25rem]`}>
                <div css={tw`flex items-center gap-1 flex-wrap`}>
                    {RESOLUTIONS.map((r) => (
                        <button
                            key={r.id}
                            type="button"
                            css={[
                                tw`px-3 py-2 rounded text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/60`,
                                resolution === r.id ? tw`text-white` : tw`text-neutral-300 shadow`,
                            ]}
                            style={{ background: resolution === r.id ? 'var(--pagePrimaryHover, hsl(213, 78%, 51%))' : 'var(--pageSecondary, hsl(209, 18%, 30%))' }}
                            onClick={() => selectResolution(r.id)}
                            aria-pressed={resolution === r.id}
                            aria-label={`Show last ${r.label}`}
                        >
                            {r.label}
                        </button>
                    ))}
                    {canManage && (
                        <button
                            type="button"
                            css={tw`p-1.5 text-red-300 transition-colors hover:text-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 disabled:opacity-50 disabled:pointer-events-none bg-transparent border-0`}
                            onClick={resetAnalytics}
                            disabled={resetting}
                            aria-label={resetting ? 'Resetting analytics…' : 'Reset analytics data'}
                            aria-busy={resetting}
                            title={resetting ? 'Resetting…' : 'Reset analytics'}
                        >
                            {resetting ? (
                                <svg
                                    width="16"
                                    height="16"
                                    viewBox="0 0 24 24"
                                    fill="none"
                                    stroke="currentColor"
                                    strokeWidth="2"
                                    strokeLinecap="round"
                                    strokeLinejoin="round"
                                    css={tw`animate-spin`}
                                    aria-hidden
                                >
                                    <path d="M21 12a9 9 0 1 1-6.219-8.56" />
                                </svg>
                            ) : (
                                <svg
                                    width="16"
                                    height="16"
                                    viewBox="0 0 24 24"
                                    fill="none"
                                    stroke="currentColor"
                                    strokeWidth="2"
                                    strokeLinecap="round"
                                    strokeLinejoin="round"
                                    aria-hidden
                                >
                                    <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
                                    <path d="M3 3v5h5" />
                                </svg>
                            )}
                        </button>
                    )}
                </div>
                <a
                    href="https://studio.pingless.org"
                    target="_blank"
                    rel="noopener noreferrer"
                    css={tw`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-xs text-neutral-500 hover:text-neutral-300 transition-colors whitespace-nowrap pointer-events-auto z-10`}
                    style={{ textDecoration: 'none' }}
                >
                    A PingLess Extension ©
                </a>
                <div css={tw`ml-auto text-xs text-neutral-400 flex items-center gap-2 flex-shrink-0`}>
                    <span css={tw`inline-block min-w-[7.5rem] text-right tabular-nums`}>
                        {loadingSeries ? 'Loading…' : `Updated ${formatRelativeTime(lastSeriesFetchedAt, relativeNow)}`}
                    </span>
                    <select
                        id="fwp-dashboard-refresh-interval"
                        value={refreshInterval}
                        onChange={(e) => {
                            const id = e.target.value as RefreshIntervalId;
                            setRefreshInterval(id);
                            storeRefreshInterval(id);
                        }}
                        aria-label="Auto-refresh interval"
                        title="How often to refresh dashboard stats while this tab is visible"
                        css={tw`text-xs rounded py-1.5 pl-2 pr-2 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/60 text-neutral-200 max-w-[7rem]`}
                        style={{
                            background: 'var(--pageSecondary, hsl(209, 18%, 30%))',
                            border: '1px solid rgba(255,255,255,0.08)',
                        }}
                    >
                        {REFRESH_INTERVAL_OPTIONS.map((o) => (
                            <option key={o.id} value={o.id}>
                                {o.label}
                            </option>
                        ))}
                    </select>
                    <button
                        type="button"
                        onClick={handleManualRefresh}
                        css={tw`p-1.5 text-neutral-200 hover:text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/60 bg-transparent border-0 flex-shrink-0`}
                        aria-label="Refresh dashboard"
                        title="Refresh"
                    >
                        <span
                            css={tw`inline-flex`}
                            style={{
                                transform: `rotate(${refreshSpinDeg}deg)`,
                                transition: 'transform 0.55s ease-in-out',
                                transformOrigin: '50% 50%',
                            }}
                        >
                            <IcoRefreshCw />
                        </span>
                    </button>
                </div>
            </div>

            {/* ── 3 main stat cards (Total traffic / Blocked / Passed) ──────── */}
            <div css={tw`mb-3`}>
                <div css={tw`grid grid-cols-1 md:grid-cols-3 gap-3`}>

                    {/* Total traffic */}
                    <div css={statCardCss} style={{ ...surfaceStyle, minHeight: STAT_CARD_MIN_H }}>
                        <div css={statBodyCss}>
                            <span css={statTitleCss}>
                                <StatTitleIcon children={<IcoArrowDownUp />} />
                                Total traffic
                            </span>
                            <div css={statMetricRowCss}>
                                <span css={tw`text-2xl font-bold text-blue-400 font-mono leading-tight`}>
                                    {formatPackets(totals.totalPackets)}
                                </span>
                                <span css={tw`text-sm font-mono text-neutral-200`}>PPS</span>
                            </div>
                            <span css={tw`text-sm text-neutral-400`}>
                                {formatBytes(totals.totalBytes)}{' '}
                                <span css={tw`text-neutral-500`}>Packet Size</span>
                            </span>
                            <span css={tw`text-sm text-neutral-400`}>
                                Avg packet size:{' '}
                                <span css={tw`text-blue-400`}>{formatBytes(totals.avgPacketSize)}</span>
                            </span>
                        </div>
                        {showStatSpark && (
                            <StatSparkline values={sparkTraffic} color="#60a5fa" trend={trends.traffic} fill={statSparkFill} />
                        )}
                    </div>

                    {/* Total blocked */}
                    <div css={statCardCss} style={{ ...surfaceStyle, minHeight: STAT_CARD_MIN_H }}>
                        <div css={statBodyCss}>
                            <span css={statTitleCss}>
                                <StatTitleIcon children={<IcoShieldX />} />
                                Total blocked
                            </span>
                            <div css={statMetricRowCss}>
                                <span css={tw`text-2xl font-bold font-mono leading-tight text-red-400`}>
                                    {formatPackets(totals.totalBlocked)}
                                </span>
                                <span css={tw`text-sm font-mono text-neutral-200`}>PPS</span>
                            </div>
                            <span css={tw`text-sm text-neutral-400`}>
                                {formatBytes(totals.totalBlockedBytes)}{' '}
                                <span css={tw`text-neutral-500`}>Packet Size</span>
                            </span>
                            <span css={tw`text-sm text-neutral-400`}>
                                Avg drop rate:{' '}
                                <span css={tw`text-red-400`}>
                                    {formatPercent(totals.avgBlockRate)}
                                </span>
                            </span>
                        </div>
                        {showStatSpark && (
                            <StatSparkline values={sparkBlocked} color="#f87171" trend={trends.blockRate} fill={statSparkFill} />
                        )}
                    </div>

                    {/* Total passed */}
                    <div css={statCardCss} style={{ ...surfaceStyle, minHeight: STAT_CARD_MIN_H }}>
                        <div css={statBodyCss}>
                            <span css={statTitleCss}>
                                <StatTitleIcon children={<IcoShieldCheck />} />
                                Total passed
                            </span>
                            <div css={statMetricRowCss}>
                                <span css={tw`text-2xl font-bold font-mono leading-tight`} style={{ color: '#34d399' }}>
                                    {formatPackets(totals.totalPassed)}
                                </span>
                                <span css={tw`text-sm font-mono text-neutral-200`}>PPS</span>
                            </div>
                            <span css={tw`text-sm text-neutral-400`}>
                                {formatBytes(totals.totalPassedBytes)}{' '}
                                <span css={tw`text-neutral-500`}>Packet Size</span>
                            </span>
                            <span css={tw`text-sm text-neutral-400`}>
                                Avg pass rate:{' '}
                                <span css={tw`text-green-400`}>{formatPercent(totals.avgPassRate)}</span>
                            </span>
                        </div>
                        {showStatSpark && (
                            <StatSparkline values={sparkPassed} color="#34d399" trend={trends.passed} fill={statSparkFill} />
                        )}
                    </div>
                </div>
            </div>

            {/* ── Secondary stat cards ───────────────────────────────────────── */}
            <div css={tw`mb-4`}>
                <div css={tw`grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2`}>

                    <div css={tw`bg-neutral-700 px-3 py-2.5 rounded shadow flex flex-col gap-1 min-w-0`} style={surfaceStyle}>
                        <span css={tw`text-base text-neutral-400 inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoArrowBigUp />} />
                            Peak rate
                        </span>
                        <span css={tw`text-xl font-bold text-neutral-100 font-mono leading-snug`}>{formatPps(totals.peakPps)}</span>
                        {totals.peakAt && <span css={tw`text-sm text-neutral-500`}>at {formatRelativeTime(totals.peakAt)}</span>}
                    </div>

                    <div css={tw`bg-neutral-700 px-3 py-2.5 rounded shadow flex flex-col gap-1 min-w-0`} style={surfaceStyle}>
                        <span css={tw`text-base text-neutral-400 inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoCirclePlus />} />
                            New connections
                        </span>
                        <span css={tw`text-xl font-bold text-neutral-100 font-mono leading-snug`}>{formatPackets(totals.totalNewConns)}</span>
                    </div>

                    <div css={tw`bg-neutral-700 px-3 py-2.5 rounded shadow flex flex-col gap-1 min-w-0`} style={surfaceStyle}>
                        <span css={tw`text-base text-neutral-400 inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoMousePointerClick />} />
                            Unique sources
                        </span>
                        <span css={tw`text-xl font-bold text-neutral-100 font-mono leading-snug`}>{totals.uniqueSources}</span>
                    </div>

                    <div css={tw`bg-neutral-700 px-3 py-2.5 rounded shadow flex flex-col gap-1 min-w-0`} style={surfaceStyle}>
                        <span css={tw`text-base text-neutral-400 inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoBrickWallFire />} />
                            Active rules
                        </span>
                        <span css={tw`text-xl font-bold text-neutral-100 font-mono leading-snug`}>{allRules.length}</span>
                    </div>

                    {/* Top Active Ports */}
                    <div css={tw`bg-neutral-700 px-3 py-2.5 rounded shadow flex flex-col gap-1 min-w-0`} style={surfaceStyle}>
                        <span css={tw`text-sm text-neutral-400 inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoActivity />} />
                            Top Active Ports
                        </span>
                        {portStats.length === 0 ? (
                            <span css={tw`text-base font-bold text-neutral-400 font-mono`}>-</span>
                        ) : (
                            <>
                                <span css={tw`text-base font-bold text-neutral-100 font-mono leading-snug`}>
                                    {portStats[0].port}{' '}
                                    <span css={tw`text-sm font-normal text-neutral-400`}>({portStats[0].pct.toFixed(0)}%)</span>
                                </span>
                                {portStats.slice(1, 3).map(p => (
                                    <span key={p.port} css={tw`text-sm text-neutral-400 font-mono`}>
                                        {p.port} <span css={tw`text-neutral-500`}>({p.pct.toFixed(0)}%)</span>
                                    </span>
                                ))}
                            </>
                        )}
                    </div>

                    {/* Protocol distribution */}
                    <div css={tw`bg-neutral-700 px-3 py-2.5 rounded shadow flex flex-col gap-1 min-w-0`} style={surfaceStyle}>
                        <span css={tw`text-sm text-neutral-400 inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoStretchHorizontal />} />
                            Protocol dist.
                        </span>
                        <div css={tw`flex flex-col gap-0.5`}>
                            <div css={tw`flex items-center gap-1.5`}>
                                <span css={tw`w-2 h-2 rounded-full bg-blue-400 flex-shrink-0`} />
                                <span css={tw`text-sm text-neutral-200 font-mono`}>TCP {protoStats.tcp.toFixed(0)}%</span>
                            </div>
                            <div css={tw`flex items-center gap-1.5`}>
                                <span css={tw`w-2 h-2 rounded-full bg-purple-400 flex-shrink-0`} />
                                <span css={tw`text-sm text-neutral-200 font-mono`}>UDP {protoStats.udp.toFixed(0)}%</span>
                            </div>
                            <div css={tw`flex items-center gap-1.5`}>
                                <span css={tw`w-2 h-2 rounded-full bg-yellow-400 flex-shrink-0`} />
                                <span css={tw`text-sm text-neutral-200 font-mono`}>ICMP {protoStats.icmp.toFixed(0)}%</span>
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            {/* ── Traffic chart ──────────────────────────────────────────────── */}
            <div css={tw`mb-4`}>
                <TitledGreyBox
                    title={
                        <p css={tw`text-sm uppercase inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoChartNoAxesCombined />} />
                            Traffic over time (packets/sec)
                        </p>
                    }
                >
                    <div css={tw`relative`} style={{ height: 380 }}>
                        {buckets.length === 0 ? (
                            <div css={tw`absolute inset-0 flex items-center justify-center text-sm text-neutral-400`}>
                                No series data for this range yet.
                            </div>
                        ) : (
                            <Suspense fallback={
                                <div css={tw`absolute inset-0 flex items-center justify-center text-sm text-neutral-400`}>
                                    Loading chart…
                                </div>
                            }>
                                <FirewallChart buckets={buckets} unit="pps" fillAreas={statSparkMode === 0} resolution={resolution} />
                            </Suspense>
                        )}
                    </div>
                </TitledGreyBox>
            </div>

            {/* ── Per-rule breakdown + Top Sources (side by side) ───────────── */}
            <div css={tw`grid grid-cols-1 md:grid-cols-2 gap-4`}>

                {/* Per-rule breakdown */}
                <TitledGreyBox
                    title={
                        <p css={tw`text-sm uppercase inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoChartNoAxesGantt />} />
                            Per-rule breakdown
                        </p>
                    }
                >
                    {allRules.length === 0 ? (
                        <p css={tw`text-sm text-neutral-400`}>No rules configured yet.</p>
                    ) : (
                        <>
                            <table css={tw`w-full text-sm`}>
                                <thead>
                                    <tr css={tw`text-xs text-neutral-500 uppercase`}>
                                        <th css={tw`text-right py-2 pr-4 font-medium w-16`}>ID</th>
                                        <th css={tw`text-left py-2 pl-0 font-medium min-w-0`}>Type</th>
                                        <th css={tw`text-right py-2 font-medium`}>Packets</th>
                                        <th css={tw`text-right py-2 font-medium`}>% total</th>
                                        <th css={tw`text-right py-2 font-medium`}>% blk</th>
                                    </tr>
                                </thead>
                                <tbody>
                                    {visibleRules.map((r) => {
                                        const active = Number(r.packets_matched) > 0;
                                        const rowKey = r.rule_key ?? `id:${r.rule_id ?? ''}:${r.rule_type}`;
                                        return (
                                            <tr key={rowKey} style={{ borderTop: '1px solid rgba(255,255,255,0.04)' }}>
                                                <td css={tw`py-2 text-right align-middle`}>
                                                    {r.rule_id != null && Number.isFinite(Number(r.rule_id)) ? (
                                                        <span
                                                            css={tw`inline-block rounded px-1.5 py-0.5 text-xs font-mono font-semibold`}
                                                            style={{ background: '#171717', color: '#9ca3af' }}
                                                        >
                                                            #{r.rule_id}
                                                        </span>
                                                    ) : null}
                                                </td>
                                                <td css={tw`py-2 font-mono text-neutral-300`}>
                                                    <div css={tw`flex items-center gap-1.5`}>
                                                        <span
                                                            css={tw`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0`}
                                                            style={{ background: active ? '#34d399' : '#4b5563' }}
                                                        />
                                                        {r.rule_type}
                                                    </div>
                                                </td>
                                                <td css={tw`py-2 text-right font-mono text-neutral-400`}>
                                                    {active ? formatPackets(r.packets_matched) : '-'}
                                                </td>
                                                <td css={tw`py-2 text-right font-mono text-neutral-500`}>
                                                    {active ? formatPercent(r.pct_of_total) : '-'}
                                                </td>
                                                <td css={tw`py-2 text-right font-mono`} style={{ color: active && r.mitigates ? blockRateColor(r.pct_of_blocked) : '#4b5563' }}>
                                                    {active && r.mitigates ? formatPercent(r.pct_of_blocked) : '-'}
                                                </td>
                                            </tr>
                                        );
                                    })}
                                </tbody>
                            </table>
                            <PaginationBar
                                page={rulesPage}
                                totalPages={rulePageTotal}
                                onPrev={() => setRulesPage(p => p - 1)}
                                onNext={() => setRulesPage(p => p + 1)}
                            />
                        </>
                    )}
                </TitledGreyBox>

                {/* Top Sources */}
                <TitledGreyBox
                    title={
                        <p css={tw`text-sm uppercase inline-flex items-center gap-1`}>
                            <StatTitleIcon children={<IcoWifi />} />
                            Top sources
                            {canAbuseDb && abuseDbVerified && (
                                <span css={tw`normal-case text-[10px] text-neutral-500 font-normal ml-1`}>
                                    · click IP to look up
                                </span>
                            )}
                        </p>
                    }
                >
                    {sources.length === 0 ? (() => {
                        const hasTraffic = buckets.some((b) => Number(b.packets_in ?? 0) > 0);
                        return hasTraffic ? (
                            <p css={tw`text-sm text-neutral-400`}>
                                Traffic detected but no source IPs yet. For <strong>TCP</strong> games the node can read peers from{' '}
                                <code css={tw`text-xs bg-neutral-700 px-1 rounded`}>/proc/net/tcp</code>
                                — update the Firewall-Plus node service if this message persists. For <strong>UDP</strong> you still need connection tracking: load{' '}
                                <code css={tw`text-xs bg-neutral-700 px-1 rounded`}>nf_conntrack</code>{' '}
                                (<code css={tw`text-xs bg-neutral-700 px-1 rounded`}>modprobe nf_conntrack</code>{' '}
                                exits silently on success; check with{' '}
                                <code css={tw`text-xs bg-neutral-700 px-1 rounded`}>lsmod | grep conntrack</code>
                                {' '}and{' '}
                                <code css={tw`text-xs bg-neutral-700 px-1 rounded`}>test -r /proc/net/nf_conntrack</code>
                                ).
                            </p>
                        ) : (
                            <p css={tw`text-sm text-neutral-400`}>
                                No source data yet. Source IPs appear when the node publishes top talkers (often from connection tracking on the game ports).
                            </p>
                        );
                    })() : (
                        <>
                            <table css={tw`w-full text-sm`}>
                                <thead>
                                    <tr css={tw`text-xs text-neutral-500 uppercase`}>
                                        <th css={tw`text-left py-2 font-medium`}>#</th>
                                        <th css={tw`text-left py-2 font-medium`}>Source IP</th>
                                        <th css={tw`text-right py-2 font-medium`}>Packets</th>
                                        {sources[0]?.bytes != null && (
                                            <th css={tw`text-right py-2 font-medium`}>Bytes</th>
                                        )}
                                    </tr>
                                </thead>
                                <tbody>
                                    {visibleSources.map((s, i) => (
                                        <tr key={`${s.src}-${i}`} style={{ borderTop: '1px solid rgba(255,255,255,0.04)' }}>
                                            <td css={tw`py-2 text-neutral-600 font-mono`}>
                                                {sourcesPage * PAGE_SIZE + i + 1}
                                            </td>
                                            <td css={tw`py-2 font-mono`}>
                                                {canAbuseDb ? (
                                                    <button
                                                        type="button"
                                                        disabled={abuseDbLookup.lookingUp}
                                                        onClick={() => lookupSourceIp(s.src)}
                                                        title={
                                                            abuseDbVerified
                                                                ? 'Look up in AbuseIPDB'
                                                                : 'Configure API key in AbuseDB tab'
                                                        }
                                                        css={[
                                                            tw`bg-transparent border-0 p-0 font-mono text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/60 rounded`,
                                                            abuseDbVerified
                                                                ? tw`text-primary-400 hover:text-primary-300 hover:underline cursor-pointer disabled:opacity-50`
                                                                : tw`text-neutral-400 cursor-not-allowed`,
                                                        ]}
                                                    >
                                                        {s.src}
                                                    </button>
                                                ) : (
                                                    <span css={tw`text-neutral-300`}>{s.src}</span>
                                                )}
                                            </td>
                                            <td css={tw`py-2 text-right font-mono text-neutral-400`}>
                                                {s.packets != null ? formatPackets(s.packets) : '-'}
                                            </td>
                                            {s.bytes != null && (
                                                <td css={tw`py-2 text-right font-mono text-neutral-500`}>
                                                    {formatBytes(s.bytes)}
                                                </td>
                                            )}
                                        </tr>
                                    ))}
                                </tbody>
                            </table>
                            <PaginationBar
                                page={sourcesPage}
                                totalPages={sourcePageTotal}
                                onPrev={() => setSourcesPage(p => p - 1)}
                                onNext={() => setSourcesPage(p => p + 1)}
                            />
                        </>
                    )}
                </TitledGreyBox>
            </div>

            {showAnalyticsDocs && (
                <Suspense fallback={null}>
                    <FirewallAnalyticsDocsPopup onClose={() => setShowAnalyticsDocs(false)} />
                </Suspense>
            )}

            <AbuseDbLookupOverlay {...abuseDbLookup} />
        </div>
    );
}
