// TechMinds Speedtest — production frontend.
// Real measurement against same-origin /api/* endpoints. Visual layer is
// preserved from the original artifact; the simulated traces are gone.
const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ----------------------------- helpers -----------------------------
const fmt = (n, d = 1) => {
  if (!isFinite(n)) return '—';
  if (n >= 100) return n.toFixed(0);
  return n.toFixed(d);
};
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const easeOutExpo = t => t === 1 ? 1 : 1 - Math.pow(2, -8 * t);
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);

// Non-linear gauge mapping: speed (Mbps) -> angle (-110deg .. 110deg)
// Use piecewise log so 0..1 reads nicely AND 1..2000 fits the dial.
const TICKS = [0, 5, 10, 25, 50, 100, 250, 500, 1000];
const ANGLE_MIN = -90;
const ANGLE_MAX = 90;
function speedToAngle(mbps) {
  const v = clamp(mbps, 0, 1000);
  // log-ish: map 0..1000 to 0..1 via log10(1+v)/log10(1001)
  const t = Math.log10(1 + v) / Math.log10(1001);
  return ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * t;
}

// ----------------------------- MEASUREMENT -----------------------------
// Real network probes against same-origin /api/* endpoints. Each function
// streams real bytes (or ping replies), drives a sliding-window throughput
// estimator, and calls onSample(...) frequently so React state — and the
// dial — track what's actually on the wire. AbortController-friendly.

const PING_COUNT = 14;            // number of latency samples
const PING_GAP_MS = 70;           // pause between pings; keeps RTT independent

// Multi-origin hosts. Previous attempt (cloudflared origin) tanked DL
// because each per-host TCP needed its own slow-start to the home origin
// and 10 MiB transfers never reached steady state. That doesn't apply
// anymore: with Worker + R2 at the CF edge, slow-start is purely
// client↔edge (sub-10ms RTT, completes in a couple round-trips), and
// the cloudflared upload throttle is out of the data plane entirely.
// Fan-out across 4 distinct hostnames so the browser opens 4 distinct
// TCP/QUIC connections — Speedtest.net-style multi-stream methodology
// that breaks past single-CWND ceilings on H/2 multiplex.
const PARALLEL_HOSTS = [
  'cdn1.techmindsinc.net',
  'cdn2.techmindsinc.net',
  'cdn3.techmindsinc.net',
  'cdn4.techmindsinc.net',
];

// Probe each candidate host with a tiny ping. Drops any that fail (DNS
// error, CORS reject, 404, etc.). Returns at minimum [''] meaning
// same-origin-only fallback. The empty-string sentinel means "use
// relative URLs"; non-empty entries are absolute hostnames.
async function discoverHosts(signal) {
  if (!PARALLEL_HOSTS.length) return [''];
  // Reached by IP or from outside techmindsinc.net (LAN tests, direct-path tests): stay same-origin so the
  // measurement is the path the visitor actually used, not a mix with the Cloudflare-served cdn hosts.
  // Only the Cloudflare-served page (speedtest.techmindsinc.net / cdnN) fans out to the cdn hosts; the direct
  // hostname (speed.techmindsinc.net, HTTP/1.1 → real parallel TCP on one host) and IP tests measure their own path.
  if (!/^(speedtest|cdn\d+)\.techmindsinc\.net$/i.test(location.hostname)) return [''];
  const results = await Promise.all(PARALLEL_HOSTS.map(async (host) => {
    try {
      const r = await fetch(`https://${host}/api/ping?_=probe_${Date.now()}`, {
        cache: 'no-store',
        signal,
        mode: 'cors',
      });
      return r.ok ? host : null;
    } catch {
      return null;
    }
  }));
  const working = results.filter(h => h !== null);
  // Need at least 2 hosts to get any benefit. Otherwise fall back to
  // same-origin so we don't spend extra DNS/TLS time for no gain.
  return working.length >= 2 ? working : [''];
}

// Build a URL targeted at one of the multi-origin hosts, or relative
// when host is the empty-string sentinel (same-origin fallback).
function originUrl(host, path) {
  return host ? `https://${host}${path}` : path;
}

// Multi-size sampling, mirroring speed.cloudflare.com's methodology:
// many small transfers probe RTT-dominated regimes, fewer large transfers
// probe bandwidth. The final number is the 90th-percentile throughput
// across every individual test. Small transfers under-read on RTT-heavy
// links — p90 inherently selects the bandwidth-dominated tail and ignores
// them.
// Adaptive ladder, matching speed.cloudflare.com exactly: sequential single
// streams (HTTP/2 over one TCP conn). Each bucket gates the next via
// `requireMbps` — the median throughput observed in the previous bucket has
// to clear the threshold or we bail. Slow lines stop at 10 MiB; fast lines
// run the full ladder.
//
// We tried parallel HTTP/2 fan-out for the big tiers; it didn't actually
// add bandwidth (multiple streams just share the conn's cwnd) and the
// per-stream samples were less representative of true line throughput.
const DL_BUCKETS = [
  { bytes: 100 * 1024,        count: 10, requireMbps: 0,   parallel: 1 }, // 100 KiB × 10  — RTT-dominated, single
  { bytes: 1 * 1024 * 1024,   count: 8,  requireMbps: 0,   parallel: 1 }, // 1 MiB × 8     — partial slow-start
  { bytes: 10 * 1024 * 1024,  count: 16, requireMbps: 0,   parallel: 8 }, // 10 MiB × 16   — 8 parallel
  { bytes: 25 * 1024 * 1024,  count: 16, requireMbps: 50,  parallel: 8 }, // 25 MiB × 16   — 8 parallel
  { bytes: 100 * 1024 * 1024, count: 8,  requireMbps: 200, parallel: 8 }, // 100 MiB × 8   — 8 parallel
];
// Multi-stream methodology, matching Speedtest.net (Ookla). On a gigabit
// line a single TCP stream is CWND-limited well below wire (a 200 ms RTT
// cloudflared path with 6 MB CWND tops out around 240 Mbps). Running
// 4 concurrent fetches and reporting the SUM of per-stream tail
// throughputs recovers the wire-saturation number — what your line
// actually delivers when multiple connections share it. This is what
// Speedtest.net does (they use 4-16 parallel TCP streams); the resulting
// numbers will read 30-60 % higher than speed.cloudflare.com's
// single-stream-style measurement, by design.
// Upload chunks stay reasonable (≤ 25 MiB) so the browser's per-XHR memcpy
// doesn't lock the main thread the way 50 MiB bodies did. Gates are
// permissive — the goal is to let the test run as far up the ladder as the
// line can actually sustain, not to bail aggressively. Phase budget is the
// real backstop on slow lines.
const UL_BUCKETS = [
  { bytes: 100 * 1024,        count: 8,  requireMbps: 0,   parallel: 1 }, // 100 KiB — RTT-dominated
  { bytes: 1 * 1024 * 1024,   count: 12, requireMbps: 0,   parallel: 1 }, // 1 MiB  — partial slow-start
  { bytes: 10 * 1024 * 1024,  count: 8,  requireMbps: 20,  parallel: 1 }, // 10 MiB — TCP mostly converged
  { bytes: 25 * 1024 * 1024,  count: 6,  requireMbps: 80,  parallel: 1 }, // 25 MiB — sustained throughput
  { bytes: 100 * 1024 * 1024, count: 4,  requireMbps: 200, parallel: 1 }, // 100 MiB — gigabit-saturating
];
const DL_BUDGET_MS = 30000;        // hard wall-clock ceiling per phase. Tests
const UL_BUDGET_MS = 30000;        // are sequential within a bucket, so slow
                                   // lines naturally bail out before they
                                   // burn 60+ seconds on the big buckets.
                                   // 30s gives mid-tier lines (150–300 Mbps)
                                   // room to finish the 100 MiB / 25 MiB
                                   // top bucket without being truncated.

const SAMPLE_WINDOW_MS = 1000;    // legacy sliding-window helper, kept for
                                  // bufferbloat which still needs sustained-load
                                  // sampling
const FINAL_TAIL_MS = 2500;

// Dial readout uses peak-hold-with-decay so the needle lingers on the
// line's best moment instead of snapping to whatever the window reports
// right now. Each sampler tick: dial = max(live, prev * decayFactor),
// where decayFactor is computed from elapsed wall time since the last
// tick — exp(ln(0.5) * dt / HALFLIFE_MS) — so the decay rate is the
// SAME on the fast download sampler (~12 ticks/s) and the upload
// sampler (which often gets starved to ~1 tick/s by XHR/React work).
// Before this was per-tick at 0.98, which meant download decayed at
// 22 %/s while upload only decayed at 2 %/s, so users saw downloads
// snap back from 705 → 250 in 4 s while upload looked fine.
//
// HALFLIFE = 40 s, applied EVERY tick (not gated on activity). Always-on
// decay gives the needle visible rhythm — it drifts down a touch between
// per-bucket samples and jumps up when a new high arrives. An earlier
// version of this code suppressed decay whenever data was flowing; the
// result was a needle that climbed monotonically to the peak and then
// sat there for the rest of the phase, which felt stuck.
// At 40 s half-life: a 705 Mbps peak drifts to ~650 after 5 s and
// ~590 after 10 s. We previously ran 15 s, which was too aggressive —
// users saw the needle drop from 705 to 500 between bucket samples and
// the test ended with a 200 Mbps jump back up to the headline. 40 s
// keeps the needle within ~15 % of peak across a typical phase, so the
// final-number latch is a small step rather than a leap.
//
// The "live" value for the dial is win.tail(DIAL_TAIL_MS) — last 500 ms
// only — not win.instant() over the full 1 s window. Reason: the
// per-bucket sample uses tailMbps over the last half of each transfer
// (steady-state, post-slow-start). win.instant() averages slow-start
// ramp in, so its peaks were reading 20-30 % below the tail throughput
// that feeds p90 — users saw the dial top out at 540 while the headline
// reported 660. Same 500 ms slice now feeds both, so what's on screen
// matches what gets reported.
const DIAL_PEAK_HALFLIFE_MS = 40000;
const DIAL_TAIL_MS = 500;

// bytes/ms -> Mbps. (bytes / ms) * 8 = bits/ms = kbits/s. /1000 = Mbps.
const bytesPerMsToMbps = (bytes, ms) => (bytes * 8) / (ms * 1000);

function median(arr) {
  if (!arr.length) return 0;
  const s = [...arr].sort((a, b) => a - b);
  const m = s.length >> 1;
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}

async function measureLatency({ signal, onSample }) {
  const samples = [];
  let attempted = 0;
  let failed = 0;
  for (let i = 0; i < PING_COUNT; i++) {
    if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
    attempted++;
    const t0 = performance.now();
    try {
      const r = await fetch('/api/ping?_=' + Date.now() + '_' + i, { cache: 'no-store', signal });
      if (!r.ok) { failed++; continue; }
      // drain (should be 204 with no body, but be defensive)
      await r.arrayBuffer();
    } catch (err) {
      if (err.name === 'AbortError') throw err;
      failed++;
      continue;
    }
    const rtt = performance.now() - t0;
    samples.push(rtt);
    if (samples.length >= 3) {
      const med = median(samples);
      const mad = samples.reduce((s, v) => s + Math.abs(v - med), 0) / samples.length;
      onSample({ ping: med, jit: mad });
    }
    if (i < PING_COUNT - 1) await new Promise(r => setTimeout(r, PING_GAP_MS));
  }
  // Trim outliers (top + bottom) before final stats
  const sorted = [...samples].sort((a, b) => a - b);
  const trimmed = sorted.length >= 6 ? sorted.slice(1, -1) : sorted;
  const ping = median(trimmed);
  const jit = trimmed.reduce((s, v) => s + Math.abs(v - ping), 0) / Math.max(1, trimmed.length);
  const lossPct = attempted ? failed / attempted : 0;
  return { ping, jit, lossPct, samples };
}

// Shared sliding-window helper used by both download and upload.
function makeThroughputWindow() {
  const ev = []; // {t, bytes}
  return {
    push(bytes) {
      const t = performance.now();
      ev.push({ t, bytes });
      const cutoff = t - SAMPLE_WINDOW_MS;
      while (ev.length && ev[0].t < cutoff) ev.shift();
    },
    instant() {
      if (ev.length < 2) return 0;
      const span = ev[ev.length - 1].t - ev[0].t;
      if (span < 50) return 0;
      const bytes = ev.reduce((s, x) => s + x.bytes, 0);
      return bytesPerMsToMbps(bytes, span);
    },
    tail(durationMs) {
      // Average throughput over the LAST durationMs (bytes / durationMs).
      // We used to divide by the span between first and last event in the
      // window — that exaggerated burst-y signals: when 3 parallel upload
      // chunks ack within ~30 ms of each other, span-based math produced
      // 6 MiB / 30 ms ≈ 1.6 Gbps phantom peaks even though the line was
      // really doing ~400 Mbps. Window-based denominator caps the rate
      // at what physically flowed in the period, matching the convention
      // tailMbps already uses for per-bucket samples.
      const cutoff = performance.now() - durationMs;
      let bytes = 0;
      for (const e of ev) if (e.t >= cutoff) bytes += e.bytes;
      if (bytes === 0) return 0;
      return bytesPerMsToMbps(bytes, durationMs);
    },
  };
}

// Wall-clock window aggregator (2026-09-20). Throughput is the bytes that crossed the link in a shared time
// window, summed over EVERY concurrent stream — never a sum of per-stream rates measured at different moments.
// The old per-stream "tail" sum read a 1 Gbit/s link as 2.2-2.7 Gbit/s once parallel streams finished
// staggered (each survivor's share rises as others finish; summing rates from different periods counts the
// link several times). A shared window is physically bounded by the link and needs no correction.
const WINDOW_MS = 250;
const RAMP_MS_DL = 1000; // ignore TCP slow-start at the front of a batch
const RAMP_MS_UL = 750;
function makeWindowAggregator(t0) {
  const bins = new Map(); // bin index -> bytes
  return {
    add(bytes, at = performance.now()) {
      const k = Math.floor((at - t0) / WINDOW_MS);
      bins.set(k, (bins.get(k) || 0) + bytes);
    },
    // Credit `bytes` across the windows the transfer actually occupied, in proportion to how much of
    // [fromMs, toMs] falls in each. Point-crediting a whole chunk at its ack instant is what made six
    // 8 MiB uploads acking together read 1.3 Gbit/s on a 1 Gbit/s link: the bytes took ~420 ms to cross
    // the wire but landed in one 250 ms bin.
    addSpread(bytes, fromMs, toMs) {
      const span = toMs - fromMs;
      if (!(span > 0)) return this.add(bytes, toMs);
      const k0 = Math.floor((fromMs - t0) / WINDOW_MS);
      const k1 = Math.floor((toMs - t0) / WINDOW_MS);
      for (let k = k0; k <= k1; k++) {
        const binStart = t0 + k * WINDOW_MS;
        const overlap = Math.min(toMs, binStart + WINDOW_MS) - Math.max(fromMs, binStart);
        if (overlap > 0) bins.set(k, (bins.get(k) || 0) + (bytes * overlap) / span);
      }
    },
    // Mbps per window fully inside [fromMs, toMs] (absolute performance.now() times).
    rates(fromMs, toMs) {
      const k0 = Math.ceil((fromMs - t0) / WINDOW_MS);
      const k1 = Math.floor((toMs - t0) / WINDOW_MS) - 1;
      const out = [];
      for (let k = k0; k <= k1; k++) out.push(bytesPerMsToMbps(bins.get(k) || 0, WINDOW_MS));
      return out;
    },
  };
}

// Aggregate a set of (throughput) samples into a final number. We use the
// 90th percentile — same convention as speed.cloudflare.com. p90 across a
// mix of small-and-large-file results filters out the RTT-dominated lows
// that small files report and keeps the bandwidth-saturated highs from
// large files. p50 underreports; max is too jittery; p90 is the sweet spot.
function p90(samples) {
  if (!samples.length) return 0;
  const sorted = [...samples].sort((a, b) => a - b);
  const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9));
  return sorted[idx];
}

// One download. Returns {bytes, dt} or null on failure. Pushes per-chunk to
// the throughput window so the live dial moves continuously mid-transfer.
// Compute throughput from a list of timestamped byte events using only the
// "tail" of the transfer — the bandwidth-saturated portion after TCP
// slow-start has finished ramping. For short transfers (dt < ~200 ms),
// there's no useful tail, so falls back to overall bytes/dt. Returns Mbps.
//
// This is the difference between a per-test sample that reflects "what
// the line actually delivers" vs one that's dragged down by the slow-start
// portion of every transfer. CF's measurements skew high precisely because
// they sample steady-state, not whole-transfer averages.
function tailMbps(events, t0, tEnd, totalBytes) {
  const dt = tEnd - t0;
  if (dt < 1 || totalBytes < 1) return 0;
  const overall = (totalBytes * 8) / (dt * 1000);
  // Tail = last min(half-of-dt, 1 s). Below 200 ms total there's no useful
  // tail; use overall.
  if (dt < 200 || events.length < 2) return overall;
  const tailSpan = Math.min(dt / 2, 1000);
  const cutoff = tEnd - tailSpan;
  let tailBytes = 0;
  for (const e of events) if (e.t > cutoff) tailBytes += e.bytes;
  if (tailBytes < 1) return overall;
  // Return the tail throughput — that's the steady-state reading after
  // slow-start. Previously we returned max(tail, overall), which sounded
  // safe but quietly inflated every sample: any transfer that started fast
  // and tapered (kernel-buffer flush is the common one) reported its peak
  // instead of its sustainable rate.
  return (tailBytes * 8) / (tailSpan * 1000);
}

async function downloadOnce(bucket, i, win, signal, host = '', agg = null) {
  const t0 = performance.now();
  let response;
  try {
    response = await fetch(
      originUrl(host, `/api/download?bytes=${bucket.bytes}&_=${i}_${t0}`),
      { cache: 'no-store', signal, mode: host ? 'cors' : 'same-origin' }
    );
  } catch (err) {
    if (err.name === 'AbortError') throw err;
    return null;
  }
  if (!response.ok || !response.body) return null;
  const reader = response.body.getReader();
  let bytes = 0;
  const events = []; // { t, bytes } for tail-window throughput
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      if (value) {
        bytes += value.byteLength;
        win.push(value.byteLength);
        if (agg) agg.add(value.byteLength);
        events.push({ t: performance.now(), bytes: value.byteLength });
      }
    }
  } catch (err) {
    if (err.name === 'AbortError') throw err;
    return null;
  }
  const tEnd = performance.now();
  const dt = tEnd - t0;
  if (dt < 1 || bytes < 1) return null;
  return { bytes, dt, t0, tEnd, mbps: tailMbps(events, t0, tEnd, bytes) };
}

// Samples below this bucket size aren't reliable measurements of sustained
// line bandwidth — they're dominated by TCP slow-start, TLS resumption
// overhead, and per-request RTT. Keep them for adaptive bucket gating
// (their median decides whether to escalate) but exclude from the final
// p90, otherwise they drag the headline number down by hundreds of Mbps
// on fast lines. Matches CF's behavior: their UI shows individual small
// tests reading 50–150 Mbps but the final reported number reflects only
// the bandwidth-saturated tests.
const FINAL_MIN_BUCKET_BYTES = 10 * 1024 * 1024;

function finalMbps(samples) {
  const big = samples.filter(s => s.bucketBytes >= FINAL_MIN_BUCKET_BYTES).map(s => s.mbps);
  const pool = big.length ? big : samples.map(s => s.mbps);
  // Trimmed mean of the steady-state windows: drop the slowest 20% (a window clipped by a stream finishing,
  // a scheduler hiccup) and the fastest 10% (the browser handing JS a burst of buffered bytes, which credits
  // an earlier window's data to this one). What is left is bytes-over-time on a saturated link — the same
  // shape Ookla uses. A percentile alone read up to 991 Mbit/s on a path whose payload ceiling is ~941.
  if (pool.length < 5) return median(pool);
  const sorted = [...pool].sort((a, b) => a - b);
  const lo = Math.floor(sorted.length * 0.20);
  const hi = Math.max(lo + 1, Math.ceil(sorted.length * 0.90));
  const kept = sorted.slice(lo, hi);
  return kept.reduce((t, v) => t + v, 0) / kept.length;
}

async function measureDownload({ signal, onSample, hosts = [''] }) {
  // Samples = per-window aggregate throughput (all streams of a batch, one shared clock) taken while every
  // stream of the batch is still loading the link; the final number is the p90 of those windows over the
  // big buckets. The sliding window `win` feeds the live dial with the same physically-bounded aggregate.
  const samples = []; // { mbps, bucketBytes }
  const win = makeThroughputWindow();
  const phaseStart = performance.now();
  const agg = makeWindowAggregator(phaseStart);
  let totalBytes = 0;
  let peakDisplay = 0;
  let lastDecayAt = performance.now();
  const sampler = setInterval(() => {
    const now = performance.now();
    const elapsed = now - lastDecayAt;
    lastDecayAt = now;
    const decayed = peakDisplay * Math.pow(0.5, elapsed / DIAL_PEAK_HALFLIFE_MS);
    peakDisplay = Math.max(win.tail(DIAL_TAIL_MS), decayed);
    onSample(peakDisplay);
  }, 150);

  try {
    let lastBucketMbps = Infinity; // first bucket always runs
    outer: for (const bucket of DL_BUCKETS) {
      if (lastBucketMbps < bucket.requireMbps) break;
      const bucketStart = samples.length;
      // Same-origin HTTP/1.1 gives at most 6 sockets per host; a 7th and 8th fetch would only queue.
      const parallel = hosts.length > 1 ? (bucket.parallel || 1) : Math.min(bucket.parallel || 1, 6);

      for (let i = 0; i < bucket.count; i += parallel) {
        if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
        if (performance.now() - phaseStart > DL_BUDGET_MS) break outer;

        const batchCount = Math.min(parallel, bucket.count - i);
        const batchStart = performance.now();
        const batchResults = await Promise.all(
          Array.from({ length: batchCount }, (_, p) =>
            downloadOnce(bucket, i + p, win, signal, hosts[(i + p) % hosts.length], agg)
          )
        );
        const ok = batchResults.filter(Boolean);
        if (!ok.length) continue;
        const batchBytes = ok.reduce((n, r) => n + r.bytes, 0);
        totalBytes += batchBytes;
        const firstFinish = Math.min(...ok.map(r => r.tEnd));
        const lastFinish = Math.max(...ok.map(r => r.tEnd));
        // Windows while ALL streams of the batch were active (after the slow-start ramp).
        const windows = agg.rates(batchStart + RAMP_MS_DL, firstFinish);
        if (windows.length >= 2) {
          for (const w of windows) samples.push({ mbps: w, bucketBytes: bucket.bytes });
        } else {
          // Short batch: the whole-batch average over its wall time is the only honest number.
          samples.push({ mbps: bytesPerMsToMbps(batchBytes, Math.max(1, lastFinish - batchStart)), bucketBytes: bucket.bytes });
        }
      }
      const bucketSamples = samples.slice(bucketStart).map(s => s.mbps);
      if (bucketSamples.length) lastBucketMbps = median(bucketSamples);
    }
  } finally {
    clearInterval(sampler);
  }

  if (samples.length === 0) {
    throw new Error('Download failed: no successful tests completed');
  }
  const mbps = finalMbps(samples);
  onSample(mbps);
  return { mbps, bytes: totalBytes, samples };
}

// Upload transport (2026-09-20): UL_STREAMS concurrent rolling POSTs of Blob bodies. Same idea as the
// download side — real parallel TCP on HTTP/1.1, bytes credited to a shared wall-clock window at each ack.
// The previous engine sent 2 MiB requests through a 16-worker XHR pipeline that could only issue the next
// request after the previous ack reached the main thread; every render stall idled the link and a gigabit
// line read ~210 Mbit/s. Blob bodies are handed to the network process once (no per-request copy); streaming
// request bodies are deliberately avoided (need HTTP/2 in Chrome, unsupported for upload elsewhere).
const UL_CHUNK_MAX_BYTES = 8 * 1024 * 1024;
const UL_STREAMS_SAME_ORIGIN = 6;   // browsers cap HTTP/1.1 at 6 sockets per host
const UL_STREAMS_MULTI_HOST = 8;
const UL_SESSION_MS = 3000;          // one rolling session per big tier

async function uploadChunk({ body, signal, host = '' }) {
  const startedAt = performance.now();
  const r = await fetch(originUrl(host, `/api/upload?_=${Date.now()}_${Math.random().toString(36).slice(2, 8)}`), {
    method: 'POST', body, cache: 'no-store', signal, mode: host ? 'cors' : 'same-origin',
    headers: { 'Content-Type': 'application/octet-stream' },
  });
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  await r.arrayBuffer().catch(() => {}); // drain the tiny JSON ack
  return { startedAt, ackedAt: performance.now() };
}

// Small tiers (≤ 1 MiB): one POST, one sample (overall average) — used for gating only.
async function uploadSmall(blob, bucket, win, signal, failures, hosts) {
  const t0 = performance.now();
  try { await uploadChunk({ body: blob, signal, host: hosts[0] }); }
  catch (err) { if (err.name === 'AbortError') throw err; failures.push(err.message || String(err)); return null; }
  const dt = performance.now() - t0;
  if (dt < 1) return null;
  win.push(bucket.bytes);
  return { bytes: bucket.bytes, mbps: bytesPerMsToMbps(bucket.bytes, dt) };
}

// Big tiers: UL_STREAMS workers keep POSTing `blob` for UL_SESSION_MS; samples = per-window aggregate rates
// after the ramp. Returns { bytes, windows } or null.
async function uploadRolling(blob, win, agg, signal, failures, hosts) {
  const streams = hosts.length > 1 ? UL_STREAMS_MULTI_HOST : UL_STREAMS_SAME_ORIGIN;
  const sessionStart = performance.now();
  const deadline = sessionStart + UL_SESSION_MS;
  let bytesAcked = 0;
  let lastAckAt = 0;
  let firstFailure = null;
  const worker = async (id) => {
    const host = hosts[id % hosts.length];
    while (!signal?.aborted && !firstFailure && performance.now() < deadline) {
      let span;
      try { span = await uploadChunk({ body: blob, signal, host }); }
      catch (err) { if (err.name === 'AbortError') return; if (!firstFailure) firstFailure = err.message || String(err); return; }
      bytesAcked += blob.size;
      if (span.ackedAt > lastAckAt) lastAckAt = span.ackedAt;
      win.push(blob.size);
      // The chunk crossed the wire between the request going out and the server's ack — spread it there.
      agg.addSpread(blob.size, span.startedAt, span.ackedAt);
    }
  };
  await Promise.all(Array.from({ length: streams }, (_, w) => worker(w)));
  if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
  if (firstFailure) failures.push(firstFailure);
  const sessionEnd = lastAckAt || performance.now();
  if (bytesAcked < 1) return null;
  // Measure only up to the last ack: the tail after it is drain time, not throughput.
  const windows = agg.rates(sessionStart + RAMP_MS_UL, sessionEnd);
  return { bytes: bytesAcked, windows: windows.length >= 2 ? windows : [bytesPerMsToMbps(bytesAcked, Math.max(1, sessionEnd - sessionStart))] };
}

async function measureUpload({ signal, onSample, hosts = [''] }) {
  const samples = []; // { mbps, bucketBytes }
  const win = makeThroughputWindow();
  const phaseStart = performance.now();
  const agg = makeWindowAggregator(phaseStart);
  let totalBytes = 0;
  const failures = [];
  let peakDisplay = 0;
  let lastDecayAt = performance.now();
  const sampler = setInterval(() => {
    const now = performance.now();
    const elapsed = now - lastDecayAt;
    lastDecayAt = now;
    const decayed = peakDisplay * Math.pow(0.5, elapsed / DIAL_PEAK_HALFLIFE_MS);
    peakDisplay = Math.max(win.tail(DIAL_TAIL_MS), decayed);
    onSample(peakDisplay);
  }, 150);

  // One random buffer of the largest chunk we will ever send; Blob bodies are views of it, built once per size.
  const masterBuf = new Uint8Array(UL_CHUNK_MAX_BYTES);
  for (let off = 0; off < masterBuf.length; off += 65536) {
    crypto.getRandomValues(masterBuf.subarray(off, Math.min(off + 65536, masterBuf.length)));
  }
  const blobOf = (bytes) => new Blob([masterBuf.subarray(0, Math.min(bytes, masterBuf.length))], { type: 'application/octet-stream' });

  try {
    let lastBucketMbps = Infinity; // first bucket always runs
    outer: for (const bucket of UL_BUCKETS) {
      if (lastBucketMbps < bucket.requireMbps) break;
      const bucketStart = samples.length;
      if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
      if (performance.now() - phaseStart > UL_BUDGET_MS) break outer;

      if (bucket.bytes <= 1024 * 1024) {
        const blob = blobOf(bucket.bytes);
        for (let i = 0; i < bucket.count; i++) {
          if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
          const r = await uploadSmall(blob, bucket, win, signal, failures, hosts);
          if (r) { samples.push({ mbps: r.mbps, bucketBytes: bucket.bytes }); totalBytes += r.bytes; }
        }
      } else {
        // Chunk so that every stream carries an equal share of this tier's size, capped at UL_CHUNK_MAX_BYTES.
        const streams = hosts.length > 1 ? UL_STREAMS_MULTI_HOST : UL_STREAMS_SAME_ORIGIN;
        const chunk = Math.max(256 * 1024, Math.min(UL_CHUNK_MAX_BYTES, Math.ceil(bucket.bytes / streams)));
        const r = await uploadRolling(blobOf(chunk), win, agg, signal, failures, hosts);
        if (r) { for (const w of r.windows) samples.push({ mbps: w, bucketBytes: bucket.bytes }); totalBytes += r.bytes; }
      }
      const bucketSamples = samples.slice(bucketStart).map(s => s.mbps);
      if (bucketSamples.length) lastBucketMbps = median(bucketSamples);
    }
  } finally {
    clearInterval(sampler);
  }

  if (samples.length === 0) {
    throw new Error('Upload failed: ' + (failures[0] || 'no successful tests completed'));
  }
  const mbps = finalMbps(samples);
  onSample(mbps);
  return { mbps, bytes: totalBytes, samples };
}

// ----------------------------- BUFFERBLOAT -----------------------------
// Bufferbloat = how much your ping degrades under sustained load. Predicts
// video-call quality far better than raw download speed alone.
//
// Procedure:
//   1. Sample ping for ~1.2s with no other load (unloaded baseline).
//   2. Kick off N parallel sustained downloads.
//   3. Keep sampling ping for ~7s while downloads saturate the line.
//   4. Compare median loaded RTT vs median unloaded RTT; grade by delta.
//
// All client-orchestrated — reuses /api/ping and /api/download. No new
// backend endpoint needed.

const BB_UNLOADED_DURATION_MS = 1200;
const BB_LOADED_DURATION_MS = 7000;
const BB_PING_INTERVAL_MS = 200;
const BB_DOWNLOAD_STREAMS = 4;
const BB_DOWNLOAD_BYTES = 200 * 1024 * 1024; // 200 MiB per stream — server caps at 1 GiB

// Waveform-style thresholds, in *added* milliseconds during load.
function bufferbloatGrade(addedMs) {
  if (addedMs < 5)   return { grade: 'A+', headline: 'Pristine.',     subline: 'Latency is rock-steady under load — calls and games will feel native.' };
  if (addedMs < 30)  return { grade: 'A',  headline: 'Excellent.',    subline: 'Negligible bloat. Streaming, calls, and gaming all comfortable.' };
  if (addedMs < 60)  return { grade: 'B',  headline: 'Good.',         subline: 'Minor latency bump under load — fine for most uses.' };
  if (addedMs < 200) return { grade: 'C',  headline: 'Workable.',     subline: 'Noticeable lag spikes when the line is busy.' };
  if (addedMs < 400) return { grade: 'D',  headline: 'Bloated.',      subline: 'Calls will stutter, games will rubber-band whenever something else uses the link.' };
  return                  { grade: 'F',  headline: 'Severely bloated.', subline: 'The line is unusable for real-time anything during sustained downloads. Likely fixable with router QoS.' };
}

async function measureBufferbloat({ signal, onSample, onPhase }) {
  const samples = []; // {t, rttMs, phase: 'unloaded' | 'loaded'}
  const startMs = performance.now();
  let stop = false;
  const dlAbort = new AbortController();
  const onParentAbort = () => { stop = true; dlAbort.abort(); };
  signal?.addEventListener('abort', onParentAbort);

  let downloadStartMs = null; // set when load phase begins

  const pingLoop = async () => {
    while (!stop) {
      const now = performance.now();
      const elapsed = now - startMs;
      const phase = downloadStartMs == null ? 'unloaded' : 'loaded';
      const t0 = performance.now();
      try {
        const r = await fetch('/api/ping?_=' + Date.now() + '_bb_' + samples.length, {
          cache: 'no-store',
          signal,
        });
        if (!r.ok) continue;
        await r.arrayBuffer();
      } catch (err) {
        if (err.name === 'AbortError') return;
        continue;
      }
      const rtt = performance.now() - t0;
      const sample = { t: elapsed, rttMs: rtt, phase };
      samples.push(sample);
      onSample?.(sample, samples);
      const elapsedAfter = performance.now() - startMs;
      const targetEnd = BB_UNLOADED_DURATION_MS + BB_LOADED_DURATION_MS;
      if (elapsedAfter >= targetEnd) return;
      await new Promise(r => setTimeout(r, BB_PING_INTERVAL_MS));
    }
  };

  const downloadLoop = async () => {
    // Wait until the unloaded window completes
    await new Promise(r => setTimeout(r, BB_UNLOADED_DURATION_MS));
    if (stop) return;
    onPhase?.('loaded');
    downloadStartMs = performance.now() - startMs;
    // End the load window cleanly — without this nothing inside the
    // consume() workers ever sets stop=true and we'd only escape via the
    // outer hard-ceiling timeout.
    const deadline = setTimeout(() => {
      stop = true;
      dlAbort.abort();
    }, BB_LOADED_DURATION_MS);
    // Fire N parallel streams. Each one we drain in a tight loop without
    // computing throughput — the goal is just to keep the link saturated.
    const consume = async (idx) => {
      while (!stop) {
        try {
          const r = await fetch(`/api/download?bytes=${BB_DOWNLOAD_BYTES}&_=bb_${idx}_${Date.now()}`, {
            cache: 'no-store',
            signal: dlAbort.signal,
          });
          if (!r.ok || !r.body) return;
          const reader = r.body.getReader();
          while (!stop) {
            const { done } = await reader.read();
            if (done) break;
          }
        } catch { return; }
      }
    };
    try {
      await Promise.allSettled(
        Array.from({ length: BB_DOWNLOAD_STREAMS }, (_, i) => consume(i))
      );
    } finally {
      clearTimeout(deadline);
    }
  };

  onPhase?.('unloaded');
  try {
    await Promise.race([
      Promise.all([pingLoop(), downloadLoop()]),
      // Hard ceiling — should never fire now that downloadLoop has its own
      // deadline, but keeps a stuck fetch from hanging the test forever.
      new Promise((_, rej) => setTimeout(() => rej(new Error('bufferbloat-timeout')), BB_UNLOADED_DURATION_MS + BB_LOADED_DURATION_MS + 5000)),
    ]);
  } finally {
    stop = true;
    dlAbort.abort();
    signal?.removeEventListener('abort', onParentAbort);
  }

  // Compute metrics
  const unloaded = samples.filter(s => s.phase === 'unloaded').map(s => s.rttMs);
  // Skip the first 1s of "loaded" phase so we measure steady-state, not ramp-up
  const loadedSettleStart = downloadStartMs ?? BB_UNLOADED_DURATION_MS;
  const loaded = samples
    .filter(s => s.phase === 'loaded' && s.t >= loadedSettleStart + 1000)
    .map(s => s.rttMs);

  if (unloaded.length === 0 || loaded.length === 0) {
    throw new Error('insufficient samples — try again');
  }

  const unloadedMed = median(unloaded);
  const loadedMed = median(loaded);
  const sortedLoaded = [...loaded].sort((a, b) => a - b);
  const loadedP95 = sortedLoaded[Math.min(sortedLoaded.length - 1, Math.floor(sortedLoaded.length * 0.95))];
  const addedMs = Math.max(0, loadedMed - unloadedMed);
  const verdict = bufferbloatGrade(addedMs);

  return {
    samples,
    downloadStartMs,
    unloadedMed,
    loadedMed,
    loadedP95,
    addedMs,
    ...verdict,
  };
}

// ----------------------------- ICONS ------------------------------
const I = {
  arrow: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><polyline points="13 5 20 12 13 19"/></svg>,
  download: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12"/><polyline points="6 12 12 18 18 12"/><path d="M4 21h16"/></svg>,
  upload:   <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20V8"/><polyline points="6 12 12 6 18 12"/><path d="M4 4h16"/></svg>,
  ping:     <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12h4l2-7 4 14 2-7h8"/></svg>,
  jitter:   <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12c2 0 2-4 4-4s2 8 4 8 2-12 4-12 2 6 4 6 2-2 2-2"/></svg>,
  share:    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="m8 11 8-4M8 13l8 4"/></svg>,
  refresh:  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 15.5-6.3L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.3L3 16"/><path d="M3 21v-5h5"/></svg>,
  globe:    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>,
  shield:   <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6l-8-3Z"/><path d="m9 12 2 2 4-4"/></svg>,
};

// ----------------------------- WORDMARK -----------------------------
function Wordmark() {
  return (
    <a href="https://techminds.ca/" style={{ display: 'inline-flex', alignItems: 'center', textDecoration: 'none', userSelect: 'none', color: 'inherit' }}>
      <img src="/assets/logo.png" alt="TechMinds" style={{ height: 38, width: 'auto', display: 'block' }}/>
    </a>
  );
}

// ----------------------------- ANALOG DIAL -----------------------------
function AnalogDial({ angle, mainValue, mainLabel, phase, isInk }) {
  // SVG dimensions — cropped to upper half so digit can flow below.
  const W = 560, H = 320;
  const cx = W / 2, cy = 300; // pivot (mostly off the bottom edge — only top half of dial shown)
  const R = 230;
  const innerR = 200;
  const tickR = 220;

  // Pre-compute ticks
  const ticks = TICKS.map(v => {
    const a = speedToAngle(v);
    const rad = (a - 90) * Math.PI / 180;
    return { v, a, x1: cx + Math.cos(rad) * (tickR - 14), y1: cy + Math.sin(rad) * (tickR - 14),
             x2: cx + Math.cos(rad) * tickR, y2: cy + Math.sin(rad) * tickR,
             lx: cx + Math.cos(rad) * (tickR - 32), ly: cy + Math.sin(rad) * (tickR - 32) };
  });
  // minor ticks
  const minor = [];
  for (let v = 0; v <= 1000; v += 25) {
    if (TICKS.includes(v)) continue;
    const a = speedToAngle(v);
    const rad = (a - 90) * Math.PI / 180;
    minor.push({ x1: cx + Math.cos(rad) * (tickR - 6), y1: cy + Math.sin(rad) * (tickR - 6),
                 x2: cx + Math.cos(rad) * tickR, y2: cy + Math.sin(rad) * tickR });
  }

  const stroke = isInk ? 'rgba(255,255,255,0.85)' : '#184878';
  const hairline = isInk ? 'rgba(255,255,255,0.22)' : 'rgba(24,72,120,0.30)';
  const subtleBg = isInk ? 'rgba(255,255,255,0.04)' : '#f1f5f9';
  const grid = isInk ? 'rgba(252,250,244,0.06)' : 'rgba(26,22,18,0.05)';

  // Arc path for the outer dial sweep
  const arcStart = (ANGLE_MIN - 90) * Math.PI / 180;
  const arcEnd   = (ANGLE_MAX - 90) * Math.PI / 180;
  const ax1 = cx + Math.cos(arcStart) * R, ay1 = cy + Math.sin(arcStart) * R;
  const ax2 = cx + Math.cos(arcEnd)   * R, ay2 = cy + Math.sin(arcEnd)   * R;
  const arcPath = `M ${ax1} ${ay1} A ${R} ${R} 0 0 1 ${ax2} ${ay2}`;

  // active arc up to angle
  const angClamped = clamp(angle, ANGLE_MIN, ANGLE_MAX);
  const arcCurEnd = (angClamped - 90) * Math.PI / 180;
  const acx = cx + Math.cos(arcCurEnd) * R, acy = cy + Math.sin(arcCurEnd) * R;
  const largeArc = (angClamped - ANGLE_MIN) > 180 ? 1 : 0;
  const activeArc = `M ${ax1} ${ay1} A ${R} ${R} 0 ${largeArc} 1 ${acx} ${acy}`;

  return (
    <div style={{ position: 'relative', width: '100%', maxWidth: 640 }}>
      <svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }}>
        <defs>
          <radialGradient id="dialPaper" cx="50%" cy="100%" r="80%">
            <stop offset="0%" stopColor={isInk ? '#0e2c4b' : '#ffffff'} />
            <stop offset="100%" stopColor={isInk ? '#06121f' : '#eef3f9'} />
          </radialGradient>
          <pattern id="dialGrid" width="22" height="22" patternUnits="userSpaceOnUse">
            <path d="M 22 0 L 0 0 0 22" fill="none" stroke={grid} strokeWidth="1"/>
          </pattern>
          <filter id="paperNoise">
            <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/>
            <feColorMatrix values={isInk
              ? "0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 0.05 0"
              : "0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 0.05 0"}/>
          </filter>
        </defs>

        {/* Dial face */}
        <path d={`M ${ax1} ${ay1} A ${R} ${R} 0 0 1 ${ax2} ${ay2} L ${cx} ${cy} Z`} fill="url(#dialPaper)" />
        <path d={`M ${ax1} ${ay1} A ${R} ${R} 0 0 1 ${ax2} ${ay2} L ${cx} ${cy} Z`} fill="url(#dialGrid)" opacity="0.7" />
        <path d={`M ${ax1} ${ay1} A ${R} ${R} 0 0 1 ${ax2} ${ay2} L ${cx} ${cy} Z`} filter="url(#paperNoise)" opacity="0.18"/>

        {/* outer hairline arc */}
        <path d={arcPath} stroke={hairline} strokeWidth="1" fill="none"/>
        {/* secondary inner arc */}
        <path d={arcPath} stroke={hairline} strokeWidth="0.5" fill="none" transform={`translate(0 0)`} style={{ transform: `scale(${innerR/R})`, transformOrigin: `${cx}px ${cy}px` }}/>

        {/* minor ticks */}
        {minor.map((t,i) => (<line key={i} x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={hairline} strokeWidth="1"/>))}
        {/* major ticks */}
        {ticks.map((t,i) => (
          <g key={i}>
            <line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={stroke} strokeWidth="2"/>
            <text x={t.lx} y={t.ly + 4} textAnchor="middle"
              style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fill: stroke, letterSpacing: '0.06em' }}>
              {t.v}
            </text>
          </g>
        ))}

        {/* active sweep arc (orange when running) */}
        <path d={activeArc} stroke={phase === 'idle' || phase === 'done' ? 'var(--brand-700)' : 'var(--accent-500)'} strokeWidth="3" fill="none" strokeLinecap="round" opacity="0.85"/>

        {/* center label tickmark */}
        <text x={cx} y={cy - 138} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', fill: stroke, opacity: 0.55 }}>MBPS</text>

        {/* needle */}
        <g style={{ transformOrigin: `${cx}px ${cy}px`, transform: `rotate(${angle}deg)`, transition: 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)' }}>
          <line x1={cx} y1={cy + 14} x2={cx} y2={cy - R + 14} stroke={stroke} strokeWidth="2.2" strokeLinecap="round"/>
          <circle cx={cx} cy={cy - R + 14} r="3.5" fill={stroke}/>
        </g>
        {/* center cap */}
        <circle cx={cx} cy={cy} r="14" fill={isInk ? '#06121f' : '#ffffff'} stroke={stroke} strokeWidth="1.5"/>
        <circle cx={cx} cy={cy} r="4" fill={stroke}/>

        {/* maker stamp inside */}
        <text x={cx} y={cy - 56} textAnchor="middle" style={{ fontFamily: 'var(--font-sans)', fontWeight: 700, fontSize: 13, fill: stroke, opacity: 0.55, letterSpacing: '-0.01em' }}>TechMinds</text>
        <text x={cx} y={cy - 42} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: 8.5, letterSpacing: '0.20em', fill: stroke, opacity: 0.4 }}>SPEEDTEST · TORONTO</text>
      </svg>

      {/* big readout in normal flow, tucked just below the dial arc */}
      <div style={{ marginTop: 6, textAlign: 'center' }}>
        <div className="digit" style={{ fontSize: 'clamp(64px, 11vw, 96px)' }}>
          <span style={{ color: phase === 'idle' || phase === 'done' ? 'inherit' : 'var(--accent-600)' }}>{fmt(mainValue, mainValue >= 100 ? 0 : 1)}</span>
        </div>
        <div className="mono-cap" style={{ marginTop: 6, opacity: 0.7 }}>
          {mainLabel} · MBPS
        </div>
      </div>
    </div>
  );
}

// ----------------------------- BAR DIAL -----------------------------
function BarDial({ angle, mainValue, mainLabel, phase, isInk }) {
  const t = (angle - ANGLE_MIN) / (ANGLE_MAX - ANGLE_MIN);
  const stroke = isInk ? 'rgba(252,250,244,0.85)' : 'var(--ink-900)';
  const trackBg = isInk ? 'rgba(252,250,244,0.12)' : 'rgba(26,22,18,0.08)';
  return (
    <div style={{ width: '100%', maxWidth: 640, padding: '40px 20px' }}>
      <div className="digit" style={{ fontSize: 168, textAlign: 'left', color: phase === 'idle' || phase === 'done' ? 'inherit' : 'var(--accent-600)' }}>
        {fmt(mainValue, mainValue >= 100 ? 0 : 1)}
      </div>
      <div className="mono-cap" style={{ opacity: 0.7, marginBottom: 24 }}>{mainLabel} · MBPS</div>
      <div style={{ height: 14, background: trackBg, borderRadius: 999, overflow: 'hidden', position: 'relative' }}>
        <div style={{ position: 'absolute', inset: 0, width: `${clamp(t,0,1) * 100}%`,
          background: phase === 'idle' || phase === 'done' ? 'var(--brand-700)' : 'var(--accent-500)',
          borderRadius: 999, transition: 'width 80ms linear' }}/>
      </div>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
        {TICKS.map(v => <span key={v} className="mono-cap" style={{ opacity: 0.5 }}>{v}</span>)}
      </div>
    </div>
  );
}

// ----------------------------- DIGITAL DIAL -----------------------------
function DigitalDial({ mainValue, mainLabel, phase, downloadVal, uploadVal, pingVal, jitterVal }) {
  return (
    <div style={{ width: '100%', maxWidth: 640, padding: '20px 20px 40px', textAlign: 'center' }}>
      <div className="mono-cap" style={{ opacity: 0.6, marginBottom: 18 }}>{mainLabel === 'Idle' ? 'Awaiting test' : 'Now measuring'}</div>
      <div className="digit" style={{ fontSize: 220, color: phase === 'idle' || phase === 'done' ? 'inherit' : 'var(--accent-600)', lineHeight: 0.85 }}>
        {fmt(mainValue, mainValue >= 100 ? 0 : 1)}
      </div>
      <div className="mono-cap" style={{ marginTop: 10, opacity: 0.7 }}>{mainLabel} · MBPS</div>
    </div>
  );
}

// ----------------------------- METRIC CELL -----------------------------
function MetricCell({ icon, label, value, unit, sub, active, isInk }) {
  return (
    <div style={{ padding: '20px 4px', position: 'relative' }}>
      <div className="mono-cap" style={{ opacity: 0.6, display: 'flex', alignItems: 'center', gap: 8 }}>
        <span style={{ display: 'inline-flex', color: active ? 'var(--accent-600)' : 'currentColor' }}>{icon}</span>
        {label}
        {active && <span className="pulse-dot" style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--accent-500)' }}/>}
      </div>
      <div className="digit" style={{ fontSize: 44, marginTop: 8 }}>
        {value}
        <span className="mono-cap" style={{ marginLeft: 6, fontSize: 11, opacity: 0.55, fontFamily: 'var(--font-mono)', fontStyle: 'normal', letterSpacing: '0.16em' }}>{unit}</span>
      </div>
      {sub && <div style={{ fontFamily: 'var(--font-sans)', fontSize: 12, color: isInk ? 'rgba(252,250,244,0.55)' : 'var(--ink-500)', marginTop: 4 }}>{sub}</div>}
    </div>
  );
}

// ----------------------------- VERDICT LOGIC -----------------------------
function verdictFor({ dl, ul, ping, jit }) {
  // Returns { headline, subline, capabilities: [{label, ok, note}], grade }
  const caps = [
    { test: dl >= 25,   label: '4K streaming, multiple devices', need: '25+ Mbps down' },
    { test: dl >= 100,  label: 'Whole-house 4K + cloud backup',  need: '100+ Mbps down' },
    { test: ul >= 10,   label: 'HD video calls, no stutter',     need: '10+ Mbps up' },
    { test: ul >= 50,   label: 'Live streaming, large uploads',  need: '50+ Mbps up' },
    { test: ping <= 25, label: 'Competitive online gaming',      need: 'Ping under 25 ms' },
    { test: jit <= 3,   label: 'Pristine voice & video calls',   need: 'Jitter under 3 ms' },
  ];
  // grade
  const score = (dl/100) + (ul/30) + (50 - ping)/8 + (10 - jit)*2;
  let grade, headline, subline;
  if (dl >= 500 && ul >= 100 && ping < 12) {
    grade = 'A'; headline = 'Outstanding line.'; subline = 'Top-tier cable performance. Stream, host, mirror — go.';
  } else if (dl >= 200 && ul >= 30) {
    grade = 'B'; headline = 'Solid all-rounder.'; subline = 'Comfortable for almost everything a household throws at it.';
  } else if (dl >= 50) {
    grade = 'C'; headline = 'Workable.'; subline = 'Streams in HD; large uploads will take their time.';
  } else if (dl >= 15) {
    grade = 'D'; headline = 'Tight, but functional.'; subline = 'Single-device HD streaming, light video calls. Don\'t crowd it.';
  } else {
    grade = 'F'; headline = 'Below the working floor.'; subline = 'You\'ll feel friction on most modern services.';
  }
  return { headline, subline, capabilities: caps, grade };
}

// ----------------------------- MAIN APP -----------------------------
const HISTORY_KEY = 'tm.history';
const REPORT_NO_KEY = 'tm.reportNo';
const HISTORY_MAX = 20;

// ----------------------------- ROUTING -----------------------------
// Hash-based router: #/ (default → speedtest), #/network, #/tools. Each page
// is rendered concurrently and toggled via display:none so per-page state
// (a finished test result, a chart-mid-render, an unsubmitted form) survives
// tab switches. Memory cost is trivial for three small pages.

const ROUTES = ['speedtest', 'network', 'tools'];

function parseRoute(hash) {
  const trimmed = (hash || '').replace(/^#\/?/, '').split('/')[0].toLowerCase();
  return ROUTES.includes(trimmed) ? trimmed : 'speedtest';
}

function useRoute() {
  const [route, setRoute] = useState(() => parseRoute(typeof location !== 'undefined' ? location.hash : ''));
  useEffect(() => {
    const onHash = () => setRoute(parseRoute(location.hash));
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);
  return route;
}

// Flip to false to retire the "we're still calibrating" banner once the
// measurement logic is locked in.
const SHOW_CALIBRATION_BANNER = true;

function App() {
  // Light theme only. The logo wordmark is opaque navy and disappears on
  // dark backgrounds; until we have a dark-variant asset, dark mode is
  // disabled and isInk is hard-wired to false. The ink CSS rules in the
  // stylesheet stay (dead code is cheap) but never get triggered because
  // we never add the `theme-ink` class to body.
  const isInk = false;
  useEffect(() => {
    document.body.classList.remove('theme-ink');
    document.body.classList.add('theme-cream');
  }, []);

  const route = useRoute();

  return (
    <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
      {SHOW_CALIBRATION_BANNER && <CalibrationBanner />}
      <TopBar route={route} isInk={isInk} />
      <main style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
        <div style={{ display: route === 'speedtest' ? 'block' : 'none' }}><SpeedtestPage isInk={isInk}/></div>
        <div style={{ display: route === 'network'   ? 'block' : 'none' }}><NetworkPage   isInk={isInk}/></div>
        <div style={{ display: route === 'tools'     ? 'block' : 'none' }}><ToolsPage     isInk={isInk}/></div>
      </main>
      <Footer isInk={isInk} />
    </div>
  );
}

// ----------------------------- SPEEDTEST PAGE -----------------------------
function SpeedtestPage({ isInk }) {
  // Server/client identity is fetched from the backend on mount. Until it
  // arrives the cards render with placeholders so first paint isn't blank.
  const [server, setServer] = useState({ iata: '—', city: 'Locating server…', version: 'TM-Probe', km: null });
  const [isp, setIsp] = useState('Detecting ISP…');
  const [netInfo, setNetInfo] = useState({ ipv6: null, dnsMs: null });

  useEffect(() => {
    let cancelled = false;
    fetch('/api/info', { cache: 'no-store' })
      .then(r => r.ok ? r.json() : Promise.reject(new Error('info fetch failed')))
      .then(data => {
        if (cancelled) return;
        if (data.server) setServer(data.server);
        if (data.client?.isp) setIsp(data.client.isp);
        // DNS lookup time from PerformanceResourceTiming on this very fetch.
        const entry = performance.getEntriesByType('resource')
          .reverse()
          .find(e => e.name.endsWith('/api/info'));
        const dnsMs = entry ? Math.max(0, entry.domainLookupEnd - entry.domainLookupStart) : null;
        setNetInfo({ ipv6: !!data.client?.ipv6, dnsMs });
      })
      .catch(() => {
        if (!cancelled) setIsp('Unknown');
      });
    return () => { cancelled = true; };
  }, []);

  // Test state machine
  const [phase, setPhase] = useState('idle'); // idle | latency | download | upload | done
  const [live, setLive] = useState({ dl: 0, ul: 0, ping: 0, jit: 0 });
  const [final, setFinal] = useState(null);
  const [packetLoss, setPacketLoss] = useState(0);
  const [reportNo, setReportNo] = useState(() => {
    const v = parseInt(localStorage.getItem(REPORT_NO_KEY) || '1', 10);
    return Number.isFinite(v) && v > 0 ? v : 1;
  });
  const [history, setHistory] = useState(() => {
    try {
      const raw = localStorage.getItem(HISTORY_KEY);
      const parsed = raw ? JSON.parse(raw) : [];
      return Array.isArray(parsed) ? parsed : [];
    } catch { return []; }
  });

  const abortRef = useRef(null);

  const runTest = useCallback(async () => {
    if (phase !== 'idle' && phase !== 'done') return;
    setFinal(null);
    setPacketLoss(0);
    setLive({ dl: 0, ul: 0, ping: 0, jit: 0 });

    abortRef.current?.abort();
    const ctrl = new AbortController();
    abortRef.current = ctrl;

    try {
      // 0. Discover parallel-origin hosts. Falls back to same-origin if
      // the cdn1..N subdomains aren't set up. Runs in parallel with the
      // first part of the latency phase so it doesn't add wall time.
      const hostsP = discoverHosts(ctrl.signal);

      // 1. Latency
      setPhase('latency');
      const lat = await measureLatency({
        signal: ctrl.signal,
        onSample: ({ ping, jit }) => setLive(s => ({ ...s, ping, jit })),
      });
      setLive(s => ({ ...s, ping: lat.ping, jit: lat.jit }));
      setPacketLoss(lat.lossPct);

      const hosts = await hostsP;

      // 2. Download
      setPhase('download');
      const dl = await measureDownload({
        signal: ctrl.signal,
        hosts,
        onSample: (mbps) => setLive(s => ({ ...s, dl: mbps })),
      });
      setLive(s => ({ ...s, dl: dl.mbps }));

      // 3. Upload
      setPhase('upload');
      const ul = await measureUpload({
        signal: ctrl.signal,
        hosts,
        onSample: (mbps) => setLive(s => ({ ...s, ul: mbps })),
      });
      setLive(s => ({ ...s, ul: ul.mbps }));

      // Settle final reading and persist to history
      const reading = {
        dl: dl.mbps,
        ul: ul.mbps,
        ping: lat.ping,
        jit: lat.jit,
        ts: nowStamp(),
        server: server.iata,
      };
      setFinal(reading);
      setPhase('done');
      setReportNo(n => {
        const next = n + 1;
        localStorage.setItem(REPORT_NO_KEY, String(next));
        return next;
      });
      setHistory(h => {
        const next = [{ id: reportNo, ...reading, grade: verdictFor(reading).grade }, ...h].slice(0, HISTORY_MAX);
        try { localStorage.setItem(HISTORY_KEY, JSON.stringify(next)); } catch {}
        return next;
      });
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error('[speedtest] test failed:', err);
      }
      setPhase('idle');
    } finally {
      if (abortRef.current === ctrl) abortRef.current = null;
    }
  }, [phase, server.iata, reportNo]);

  useEffect(() => () => abortRef.current?.abort(), []);

  // Determine what's the "main" value showing on the dial
  const mainShow = (() => {
    switch (phase) {
      case 'idle':     return { value: 0, label: 'Idle' };
      case 'latency':  return { value: live.ping, label: 'Latency · ms' };
      case 'download': return { value: live.dl, label: 'Download' };
      case 'upload':   return { value: live.ul, label: 'Upload' };
      case 'done':     return { value: live.dl, label: 'Download' };
      default: return { value: 0, label: 'Idle' };
    }
  })();
  const angle = phase === 'latency' ? speedToAngle(0) : speedToAngle(mainShow.value);

  return (
    <div>
      <TickerStrip server={server} isp={isp} phase={phase} />

      <div style={{ maxWidth: 1240, width: '100%', margin: '0 auto', padding: '40px 28px 80px' }}>
        <Hero phase={phase} isInk={isInk} />

        <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.15fr) minmax(0, 0.85fr)', gap: 36, marginTop: 32 }}
             className="hero-grid">
          {/* LEFT — DIAL */}
          <section>
            <div className="card" style={{ padding: '28px 28px 36px', position: 'relative', overflow: 'hidden' }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
                <div className="mono-cap" style={{ color: 'var(--tm-cyan-600)' }}>● LIVE · {server.version || 'TM-PROBE'}</div>
                <div className="mono-cap" style={{ color: 'var(--fg-3)' }}>TEST #{String(reportNo).padStart(4,'0')}</div>
              </div>
              <div style={{ display: 'flex', justifyContent: 'center' }}>
                <AnalogDial angle={angle} mainValue={mainShow.value} mainLabel={mainShow.label} phase={phase} isInk={isInk}/>
              </div>

              {/* phase ribbon */}
              <PhaseRibbon phase={phase} />

              {/* Metric grid */}
              <div style={{ marginTop: 14 }}>
                <hr className="hr"/>
                <div className="metric-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0 }}>
                  <MetricCell icon={I.download} label="Download" value={fmt(live.dl, live.dl >= 100 ? 0 : 1)} unit="Mbps"
                    sub={phase === 'download' ? 'measuring…' : (final ? 'measured' : '—')}
                    active={phase === 'download'} isInk={isInk}/>
                  <MetricCell icon={I.upload} label="Upload" value={fmt(live.ul, live.ul >= 100 ? 0 : 1)} unit="Mbps"
                    sub={phase === 'upload' ? 'measuring…' : (final ? 'measured' : '—')}
                    active={phase === 'upload'} isInk={isInk}/>
                  <MetricCell icon={I.ping} label="Latency" value={fmt(live.ping, live.ping >= 100 ? 0 : 0)} unit="ms"
                    sub={phase === 'latency' ? 'measuring…' : (final ? 'measured' : '—')}
                    active={phase === 'latency'} isInk={isInk}/>
                  <MetricCell icon={I.jitter} label="Jitter" value={fmt(live.jit, 1)} unit="ms"
                    sub={phase === 'latency' ? 'measuring…' : (final ? 'measured' : '—')}
                    active={phase === 'latency'} isInk={isInk}/>
                </div>
              </div>

              {/* Action row */}
              <div className="speedtest-action-row" style={{ marginTop: 22, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
                <button className={`speedtest-start-btn btn ${phase==='idle' || phase==='done' ? 'btn-primary' : 'btn-ghost'} btn-lg`}
                  onClick={runTest}
                  disabled={phase !== 'idle' && phase !== 'done'}>
                  {phase === 'idle' ? <>Start speed test {I.arrow}</>
                    : phase === 'done' ? <>Test again {I.refresh}</>
                    : 'Testing…'}
                </button>
                <div style={{ flex: 1 }}/>
                <div className="mono-cap" style={{ color: 'var(--fg-3)', display: 'flex', alignItems: 'center', gap: 8 }}>
                  {I.shield} PRIVATE · NO TRACKING
                </div>
              </div>
            </div>

            {/* Field report card */}
            {phase === 'done' && final && <FieldReport reading={final} reportNo={reportNo - 1} server={server} isp={isp} isInk={isInk}/>}
          </section>

          {/* RIGHT — context cards */}
          <aside style={{ display: 'flex', flexDirection: 'column', gap: 20, alignSelf: 'start', position: 'sticky', top: 24 }}>
            <ServerCard server={server} isp={isp} phase={phase} isInk={isInk}/>
            <WeatherCard phase={phase} live={live} netInfo={netInfo} packetLoss={packetLoss} isInk={isInk} />
          </aside>
        </div>

        {/* HISTORY */}
        <HistorySection history={history} isInk={isInk} />

        {/* FAQ */}
        <FAQ isInk={isInk}/>
      </div>

      <style>{`
        @media (max-width: 920px) {
          /* minmax(0, 1fr) instead of bare 1fr: the bare form is
             minmax(auto, 1fr), and auto = content min-content, which lets
             the inner card overflow the grid cell and force the page wider
             than the viewport. The 0 floor lets children shrink properly. */
          .hero-grid { grid-template-columns: minmax(0, 1fr) !important; }
        }
        @media (max-width: 600px) {
          /* 4-up metrics become 2x2; minmax(0, 1fr) lets cells shrink below
             their natural min-content so the card can match viewport width
             instead of expanding to fit a wide loopback number like "33554". */
          .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
          .metric-grid .digit { font-size: 30px !important; }
          /* Start button: full-width, label stacks underneath */
          .speedtest-start-btn { width: 100% !important; min-width: 0 !important; }
          .speedtest-action-row { gap: 14px !important; }
        }
      `}</style>
    </div>
  );
}

// ----------------------------- NETWORK / TOOLS PAGE STUBS -----------------------------
// Filled in by subsequent build steps. The shells exist now so routing works
// end-to-end before any feature wiring lands.

function NetworkPage({ isInk }) {
  return (
    <div style={{ maxWidth: 1240, width: '100%', margin: '0 auto', padding: '40px 28px 80px' }}>
      <div className="mono-cap rule-eyebrow" style={{ color: 'var(--tm-cyan-600)', marginBottom: 18 }}>NETWORK · LINE DIAGNOSTICS</div>
      <h1 className="digit" style={{ fontSize: 'clamp(36px, 5vw, 60px)', margin: 0, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1.05 }}>
        Push your <span className="wash">connection</span>.
      </h1>
      <p style={{ fontFamily: 'var(--font-sans)', fontSize: 16, lineHeight: 1.55, maxWidth: 600, marginTop: 16, color: 'var(--fg-2)' }}>
        Diagnostics about <em>your line</em> beyond raw speed.
      </p>
      <BufferbloatCard isInk={isInk} />
    </div>
  );
}

function BufferbloatCard({ isInk }) {
  const [phase, setPhase] = useState('idle'); // idle | unloaded | loaded | done | error
  const [samples, setSamples] = useState([]);
  const [downloadStartMs, setDownloadStartMs] = useState(null);
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);
  const abortRef = useRef(null);

  const start = useCallback(async () => {
    if (phase !== 'idle' && phase !== 'done' && phase !== 'error') return;
    setSamples([]);
    setResult(null);
    setError(null);
    setDownloadStartMs(null);

    abortRef.current?.abort();
    const ctrl = new AbortController();
    abortRef.current = ctrl;

    setPhase('unloaded');
    try {
      const r = await measureBufferbloat({
        signal: ctrl.signal,
        onPhase: (p) => setPhase(p),
        onSample: (sample, all) => {
          // Update the chart progressively. Cheap copy — we're at most ~50 samples.
          setSamples(all.slice());
          if (sample.phase === 'loaded' && downloadStartMs == null) {
            setDownloadStartMs(sample.t);
          }
        },
      });
      setSamples(r.samples);
      setDownloadStartMs(r.downloadStartMs);
      setResult(r);
      setPhase('done');
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message || 'Test failed');
        setPhase('error');
      } else {
        setPhase('idle');
      }
    } finally {
      if (abortRef.current === ctrl) abortRef.current = null;
    }
  }, [phase]);

  useEffect(() => () => abortRef.current?.abort(), []);

  const running = phase === 'unloaded' || phase === 'loaded';

  return (
    <div className="card" style={{ padding: 28, marginTop: 28, maxWidth: 820 }}>
      <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 18, gap: 16, flexWrap: 'wrap' }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div className="mono-cap" style={{ color: 'var(--tm-cyan-600)' }}>● BUFFERBLOAT</div>
          <h3 style={{ margin: '4px 0 0', fontFamily: 'var(--font-sans)', fontWeight: 600, fontSize: 22 }}>
            How much your line wobbles under load
          </h3>
          <p style={{ fontFamily: 'var(--font-sans)', fontSize: 13.5, color: 'var(--fg-2)', margin: '6px 0 0' }}>
            We measure ping with the line idle, then again while pushing four sustained downloads. The gap between the two is bufferbloat — the bigger it is, the worse video calls and games feel when something else is downloading.
          </p>
        </div>
        {result && <BufferbloatGradeBadge grade={result.grade} />}
      </div>

      <BufferbloatChart
        samples={samples}
        downloadStartMs={downloadStartMs}
        result={result}
        phase={phase}
        isInk={isInk}
      />

      <div style={{ marginTop: 18, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
        <button
          onClick={start}
          disabled={running}
          className={`btn ${running ? 'btn-ghost' : 'btn-primary'} btn-md`}
          style={{ minWidth: 180 }}>
          {phase === 'idle' && 'Start bufferbloat test'}
          {phase === 'unloaded' && 'Measuring idle ping…'}
          {phase === 'loaded' && 'Pushing downloads…'}
          {phase === 'done' && 'Test again'}
          {phase === 'error' && 'Try again'}
        </button>
        {result && (
          <div style={{ display: 'flex', gap: 18, fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg-2)' }}>
            <span><span style={{ opacity: 0.55 }}>UNLOADED</span> {result.unloadedMed.toFixed(1)} ms</span>
            <span><span style={{ opacity: 0.55 }}>LOADED</span> {result.loadedMed.toFixed(1)} ms</span>
            <span><span style={{ opacity: 0.55 }}>ADDED</span> +{result.addedMs.toFixed(1)} ms</span>
          </div>
        )}
        {error && (
          <div style={{
            fontFamily: 'var(--font-sans)', fontSize: 13, color: 'var(--tm-red-500)', fontWeight: 500,
          }}>{error}</div>
        )}
      </div>

      {result && (
        <div style={{ marginTop: 18, paddingTop: 18, borderTop: '1px solid var(--border)' }}>
          <h4 style={{ margin: 0, fontFamily: 'var(--font-sans)', fontWeight: 600, fontSize: 18 }}>{result.headline}</h4>
          <p style={{ fontFamily: 'var(--font-sans)', fontSize: 14, color: 'var(--fg-2)', margin: '4px 0 0' }}>{result.subline}</p>
        </div>
      )}
    </div>
  );
}

function BufferbloatGradeBadge({ grade }) {
  const palette = {
    'A+': { bg: 'rgba(22,163,74,0.10)', fg: 'var(--forest-500)' },
    A:    { bg: 'rgba(22,163,74,0.10)', fg: 'var(--forest-500)' },
    B:    { bg: 'rgba(24,72,120,0.10)', fg: 'var(--brand-700)' },
    C:    { bg: 'rgba(245,158,11,0.12)', fg: 'var(--tm-amber-500)' },
    D:    { bg: 'rgba(245,158,11,0.18)', fg: 'var(--tm-amber-500)' },
    F:    { bg: 'rgba(220,38,38,0.10)', fg: 'var(--clay-500)' },
  };
  const p = palette[grade] || palette.A;
  return (
    <div style={{
      display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 2,
      padding: '12px 20px', borderRadius: 'var(--r-lg)', background: p.bg, color: p.fg,
      minWidth: 80,
    }}>
      <span className="mono-cap" style={{ fontSize: 9.5, opacity: 0.75, letterSpacing: '0.18em' }}>GRADE</span>
      <span className="digit" style={{ fontSize: 36, fontWeight: 700, lineHeight: 1, color: p.fg }}>{grade}</span>
    </div>
  );
}

function BufferbloatChart({ samples, downloadStartMs, result, phase, isInk }) {
  // Layout
  const W = 760, H = 220, PAD_L = 44, PAD_R = 16, PAD_T = 14, PAD_B = 28;
  const innerW = W - PAD_L - PAD_R;
  const innerH = H - PAD_T - PAD_B;
  const totalMs = BB_UNLOADED_DURATION_MS + BB_LOADED_DURATION_MS;

  // Y range — auto-scale so the small numbers are still readable, but cap min/max
  const rtts = samples.map(s => s.rttMs);
  const yMaxData = rtts.length ? Math.max(...rtts) : 50;
  const yMax = Math.max(50, Math.ceil(yMaxData / 25) * 25); // round up to nearest 25 ms
  const yMin = 0;

  const xFor = (t) => PAD_L + (Math.min(t, totalMs) / totalMs) * innerW;
  const yFor = (rtt) => PAD_T + (1 - (rtt - yMin) / (yMax - yMin)) * innerH;

  // Build the polyline — split into unloaded vs loaded segments for color
  const unloadedPts = samples.filter(s => s.phase === 'unloaded').map(s => `${xFor(s.t)},${yFor(s.rttMs)}`).join(' ');
  const loadedPts   = samples.filter(s => s.phase === 'loaded')  .map(s => `${xFor(s.t)},${yFor(s.rttMs)}`).join(' ');

  // Y-axis grid lines at 0, 25%, 50%, 75%, 100% of yMax
  const gridYs = [0, 0.25, 0.5, 0.75, 1.0].map(pct => yMin + (yMax - yMin) * pct);
  const stroke = isInk ? 'rgba(255,255,255,0.85)' : '#0b1220';
  const grid = isInk ? 'rgba(255,255,255,0.10)' : 'rgba(11,18,32,0.10)';
  const subtle = isInk ? 'rgba(255,255,255,0.45)' : 'rgba(11,18,32,0.55)';

  const loadX = downloadStartMs != null ? xFor(downloadStartMs) : null;

  return (
    <div style={{ marginTop: 14, position: 'relative' }}>
      <svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} preserveAspectRatio="xMidYMid meet">
        {/* y-axis grid */}
        {gridYs.map((rtt, i) => (
          <g key={i}>
            <line x1={PAD_L} y1={yFor(rtt)} x2={W - PAD_R} y2={yFor(rtt)} stroke={grid} strokeWidth="1"/>
            <text x={PAD_L - 6} y={yFor(rtt) + 3.5} textAnchor="end"
              style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, fill: subtle }}>
              {Math.round(rtt)}
            </text>
          </g>
        ))}
        {/* y-axis label */}
        <text x={PAD_L - 36} y={PAD_T + innerH / 2} transform={`rotate(-90 ${PAD_L - 36} ${PAD_T + innerH / 2})`}
          textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: 10, fill: subtle, letterSpacing: '0.1em' }}>
          RTT · MS
        </text>

        {/* phase background bands */}
        <rect x={PAD_L} y={PAD_T} width={(BB_UNLOADED_DURATION_MS/totalMs)*innerW} height={innerH}
          fill={isInk ? 'rgba(255,255,255,0.03)' : 'rgba(11,18,32,0.03)'}/>

        {/* x-axis baseline */}
        <line x1={PAD_L} y1={H - PAD_B} x2={W - PAD_R} y2={H - PAD_B} stroke={subtle} strokeWidth="1"/>
        {/* x-axis ticks at 0, 2, 4, 6, 8s */}
        {[0, 2000, 4000, 6000, 8000].filter(t => t <= totalMs).map((t, i) => (
          <g key={i}>
            <line x1={xFor(t)} y1={H - PAD_B} x2={xFor(t)} y2={H - PAD_B + 4} stroke={subtle} strokeWidth="1"/>
            <text x={xFor(t)} y={H - PAD_B + 16} textAnchor="middle"
              style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, fill: subtle }}>
              {(t/1000).toFixed(0)}s
            </text>
          </g>
        ))}

        {/* download-start marker */}
        {loadX != null && (
          <g>
            <line x1={loadX} y1={PAD_T} x2={loadX} y2={H - PAD_B}
              stroke="var(--tm-cyan-500)" strokeWidth="1.5" strokeDasharray="4 4" opacity="0.7"/>
            <text x={loadX + 4} y={PAD_T + 10}
              style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, fill: 'var(--tm-cyan-600)', letterSpacing: '0.08em' }}>
              LOAD
            </text>
          </g>
        )}

        {/* unloaded median guide line */}
        {result?.unloadedMed != null && (
          <line x1={PAD_L} y1={yFor(result.unloadedMed)} x2={loadX ?? (W - PAD_R)} y2={yFor(result.unloadedMed)}
            stroke="var(--forest-500)" strokeWidth="1" strokeDasharray="2 3" opacity="0.55"/>
        )}
        {/* loaded median guide line */}
        {result?.loadedMed != null && loadX != null && (
          <line x1={loadX} y1={yFor(result.loadedMed)} x2={W - PAD_R} y2={yFor(result.loadedMed)}
            stroke={result.addedMs > 60 ? 'var(--tm-red-500)' : 'var(--tm-amber-500)'} strokeWidth="1" strokeDasharray="2 3" opacity="0.7"/>
        )}

        {/* polylines */}
        {unloadedPts && (
          <polyline points={unloadedPts} fill="none" stroke="var(--forest-500)" strokeWidth="1.8" strokeLinejoin="round"/>
        )}
        {loadedPts && (
          <polyline points={loadedPts} fill="none" stroke={isInk ? 'var(--tm-cyan-300)' : 'var(--tm-cyan-600)'} strokeWidth="1.8" strokeLinejoin="round"/>
        )}

        {/* sample dots */}
        {samples.map((s, i) => (
          <circle key={i}
            cx={xFor(s.t)} cy={yFor(s.rttMs)} r="2"
            fill={s.phase === 'unloaded' ? 'var(--forest-500)' : (isInk ? 'var(--tm-cyan-300)' : 'var(--tm-cyan-600)')}
            opacity="0.9"/>
        ))}

        {/* status overlay */}
        {phase === 'idle' && samples.length === 0 && (
          <text x={W/2} y={H/2} textAnchor="middle"
            style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fill: subtle, letterSpacing: '0.12em' }}>
            READY
          </text>
        )}
      </svg>
    </div>
  );
}

function ToolsPage({ isInk }) {
  return (
    <div style={{ maxWidth: 1240, width: '100%', margin: '0 auto', padding: '40px 28px 80px' }}>
      <div className="mono-cap rule-eyebrow" style={{ color: 'var(--tm-cyan-600)', marginBottom: 18 }}>TOOLS · NETWORK UTILITIES</div>
      <h1 className="digit" style={{ fontSize: 'clamp(36px, 5vw, 60px)', margin: 0, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1.05 }}>
        Look <span className="wash">things up</span>.
      </h1>
      <p style={{ fontFamily: 'var(--font-sans)', fontSize: 16, lineHeight: 1.55, maxWidth: 600, marginTop: 16, color: 'var(--fg-2)' }}>
        Active probes against any host you point us at.
      </p>
      <DnsLookupCard isInk={isInk} />
    </div>
  );
}

const DNS_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA'];

function DnsLookupCard({ isInk }) {
  const [host, setHost] = useState('');
  const [type, setType] = useState('A');
  const [state, setState] = useState({ status: 'idle', data: null, error: null });

  const submit = useCallback(async (e) => {
    e?.preventDefault?.();
    const trimmed = host.trim();
    if (!trimmed) return;
    setState({ status: 'loading', data: null, error: null });
    try {
      const r = await fetch(`/api/tools/dig?host=${encodeURIComponent(trimmed)}&type=${encodeURIComponent(type)}`, {
        cache: 'no-store',
      });
      const json = await r.json();
      if (!r.ok) {
        setState({
          status: 'error',
          data: null,
          error: json.error === 'rate_limited'
            ? `Rate limit hit — try again in ${Math.ceil((json.retryAfterMs || 1000) / 1000)}s.`
            : json.detail || json.error || `HTTP ${r.status}`,
          retryAfterMs: json.retryAfterMs,
        });
        return;
      }
      setState({ status: 'ok', data: json, error: null });
    } catch (err) {
      setState({ status: 'error', data: null, error: err.message || 'Network error' });
    }
  }, [host, type]);

  return (
    <div className="card" style={{ padding: 28, marginTop: 28, maxWidth: 820 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}>
        <div>
          <div className="mono-cap" style={{ color: 'var(--tm-cyan-600)' }}>● DNS LOOKUP</div>
          <h3 style={{ margin: '4px 0 0', fontFamily: 'var(--font-sans)', fontWeight: 600, fontSize: 22 }}>
            Resolve a hostname
          </h3>
        </div>
        <span className="mono-cap" style={{ color: 'var(--fg-3)' }}>10 / MIN / IP</span>
      </div>

      <form onSubmit={submit} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'stretch' }}>
        <input
          type="text"
          value={host}
          onChange={(e) => setHost(e.target.value)}
          placeholder="example.com"
          spellCheck={false}
          autoCapitalize="none"
          autoCorrect="off"
          style={{
            flex: '1 1 280px', minWidth: 0,
            fontFamily: 'var(--font-mono)', fontSize: 14,
            padding: '0 14px', height: 44,
            border: '1.5px solid var(--border-strong)', borderRadius: 'var(--r-md)',
            background: 'var(--bg-surface)', color: 'var(--fg-1)',
            outline: 'none',
          }}
          onFocus={(e) => e.target.style.borderColor = 'var(--tm-cyan-500)'}
          onBlur={(e) => e.target.style.borderColor = 'var(--border-strong)'}
        />
        <select
          value={type}
          onChange={(e) => setType(e.target.value)}
          style={{
            fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600,
            padding: '0 12px', height: 44, minWidth: 90,
            border: '1.5px solid var(--border-strong)', borderRadius: 'var(--r-md)',
            background: 'var(--bg-surface)', color: 'var(--fg-1)', cursor: 'pointer',
          }}>
          {DNS_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
        </select>
        <button
          type="submit"
          disabled={state.status === 'loading' || !host.trim()}
          className="btn btn-primary btn-md"
          style={{ minWidth: 120 }}>
          {state.status === 'loading' ? 'Looking up…' : 'Look up'}
        </button>
      </form>

      <div style={{ marginTop: 22 }}>
        {state.status === 'idle' && (
          <p style={{ fontFamily: 'var(--font-sans)', fontSize: 13, color: 'var(--fg-3)', margin: 0 }}>
            Enter a hostname and pick a record type. We'll query our resolver and show what comes back.
          </p>
        )}
        {state.status === 'error' && (
          <div style={{
            padding: '12px 14px', borderRadius: 'var(--r-md)',
            background: 'rgba(220,38,38,0.08)', color: 'var(--tm-red-500)',
            fontFamily: 'var(--font-sans)', fontSize: 14, fontWeight: 500,
          }}>
            {state.error}
          </div>
        )}
        {state.status === 'ok' && <DnsRecordsTable result={state.data} isInk={isInk} />}
      </div>
    </div>
  );
}

function DnsRecordsTable({ result, isInk }) {
  const { host, type, records } = result;
  if (!records || records.length === 0) {
    return (
      <div style={{
        padding: '14px 16px', borderRadius: 'var(--r-md)',
        background: 'var(--bg-muted)', color: 'var(--fg-2)',
        fontFamily: 'var(--font-sans)', fontSize: 14,
      }}>
        No <code style={{ fontFamily: 'var(--font-mono)' }}>{type}</code> records found for <strong>{host}</strong>.
      </div>
    );
  }

  // Render shape varies by record type.
  const rows = records.map((rec, i) => {
    if (type === 'MX') {
      return [String(rec.priority).padStart(3, ' '), rec.exchange];
    }
    if (type === 'SOA') {
      return [
        ['nsname', rec.nsname], ['hostmaster', rec.hostmaster],
        ['serial', rec.serial], ['refresh', rec.refresh],
        ['retry', rec.retry], ['expire', rec.expire], ['minttl', rec.minttl],
      ].map(([k, v]) => `${k}: ${v}`).join('  ·  ');
    }
    if (type === 'TXT') {
      return [Array.isArray(rec) ? rec.join('') : rec];
    }
    return [rec];
  });

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
      <div className="mono-cap" style={{ color: 'var(--fg-3)', marginBottom: 6 }}>
        {host} · {type} · {records.length} record{records.length === 1 ? '' : 's'}
      </div>
      {type === 'SOA' && (
        <div style={{
          padding: '12px 14px', borderRadius: 'var(--r-md)',
          background: 'var(--bg-muted)', fontFamily: 'var(--font-mono)', fontSize: 12.5,
          color: 'var(--fg-1)', wordBreak: 'break-word',
        }}>
          {rows[0]}
        </div>
      )}
      {type !== 'SOA' && rows.map((cells, i) => (
        <div key={i} style={{
          display: 'flex', gap: 14, alignItems: 'baseline',
          padding: '10px 14px', borderRadius: 'var(--r-md)',
          background: i % 2 === 0 ? 'var(--bg-muted)' : 'transparent',
          fontFamily: 'var(--font-mono)', fontSize: 13.5,
          color: 'var(--fg-1)',
        }}>
          {cells.length === 2 && (
            <span style={{ color: 'var(--tm-cyan-600)', fontWeight: 600, minWidth: 36, textAlign: 'right' }}>
              {cells[0]}
            </span>
          )}
          <span style={{ flex: 1, wordBreak: 'break-word' }}>{cells[cells.length - 1]}</span>
        </div>
      ))}
    </div>
  );
}

// ----------------------------- CALIBRATION BANNER -----------------------------
// Shown above the TopBar while we tune the measurement code. Amber, not red —
// it's a heads-up, not an outage. Scrolls away naturally (not sticky) so it
// doesn't permanently eat vertical space on mobile.
function CalibrationBanner() {
  return (
    <div className="calibration-banner" style={{
      background: 'var(--tm-amber-500)',
      color: 'var(--tm-navy-900)',
      padding: '8px 22px',
      fontFamily: 'var(--font-mono)',
      fontSize: 11,
      textTransform: 'uppercase',
      letterSpacing: '0.14em',
      fontWeight: 600,
      display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
      lineHeight: 1.35,
    }}>
      <span style={{ flex: '0 0 auto', fontSize: 14 }} aria-hidden>⚠</span>
      <span className="calibration-banner-full">
        Calibration in progress — measurement logic is being tuned, results may be inaccurate.
      </span>
      <span className="calibration-banner-short" style={{ display: 'none' }}>
        Calibrating · results may be inaccurate
      </span>
      <style>{`
        @media (max-width: 600px) {
          .calibration-banner-full { display: none !important; }
          .calibration-banner-short { display: inline !important; }
        }
      `}</style>
    </div>
  );
}

// ----------------------------- TOP BAR -----------------------------
function TopBar({ route, isInk }) {
  return (
    <header style={{ position: 'sticky', top: 0, zIndex: 30, backdropFilter: 'blur(10px)',
      background: isInk ? 'rgba(6,18,31,0.82)' : 'rgba(255,255,255,0.88)',
      borderBottom: `1px solid ${isInk ? 'rgba(255,255,255,0.10)' : 'var(--border)'}` }}>
      <div style={{ maxWidth: 1240, margin: '0 auto', padding: '14px 28px', display: 'flex', alignItems: 'center', gap: 24 }}>
        <Wordmark/>
        <span className="mono-cap topbar-subtitle" style={{ opacity: 0.55, color: 'var(--fg-3)' }}>
          / {route === 'speedtest' ? 'Speedtest' : route === 'network' ? 'Network' : 'Tools'}
        </span>
        <nav style={{ display: 'flex', gap: 22, marginLeft: 28 }} className="topnav">
          {[
            { label: 'Speedtest', href: '#/',         routeKey: 'speedtest', external: false },
            { label: 'Network',   href: '#/network',  routeKey: 'network',   external: false },
            { label: 'Tools',     href: '#/tools',    routeKey: 'tools',     external: false },
            { label: 'Support',   href: 'https://techminds.ca/contact-us', routeKey: null, external: true },
          ].map((item) => {
            const active = item.routeKey === route;
            return (
              <a key={item.label}
                 href={item.href}
                 {...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
                 style={{
                   fontFamily: 'var(--font-sans)', fontSize: 14, color: 'inherit',
                   textDecoration: 'none', opacity: active ? 1 : 0.7,
                   fontWeight: active ? 600 : 400,
                   borderBottom: active ? '2px solid var(--tm-cyan-500)' : '2px solid transparent',
                   paddingBottom: 2,
                 }}>
                {item.label}
              </a>
            );
          })}
        </nav>
        <div style={{ flex: 1 }}/>
        <a href="https://techminds.ca/" className="btn btn-md btn-primary topbar-cta">Visit techminds.ca</a>
      </div>
      <style>{`
        @media (max-width: 760px) {
          .topnav { display: none !important; }
        }
        @media (max-width: 600px) {
          /* On phones the wordmark already links to techminds.ca — drop the
             redundant CTA + subtitle so the bar fits and the theme toggle stays. */
          .topbar-cta { display: none !important; }
          .topbar-subtitle { display: none !important; }
        }
      `}</style>
    </header>
  );
}

// ----------------------------- TICKER -----------------------------
// Status pill is pinned on the left (always visible — its label changes as
// the test runs). Everything else lives inside a marquee track that scrolls
// continuously on narrow viewports so all items become readable. Two
// identical sets of items are rendered so translateX(-50%) lands exactly on
// a clone, making the loop seamless. Animation is suppressed on desktop
// (where everything fits) and when prefers-reduced-motion is on.
function TickerStrip({ server, isp, phase }) {
  const items = [
    `Server · ${server.city} (${server.iata})`,
    `ISP · ${isp}`,
    `Protocol · TCP / TLS 1.3`,
    `Test engine · ${server.version || 'TM-Probe'}`,
  ];
  const statusLabel = phase === 'idle' ? 'READY'
    : phase === 'done' ? 'COMPLETE'
    : `MEASURING · ${phase.toUpperCase()}`;

  const renderSet = (clone) => (
    <div
      className={`ticker-set${clone ? ' ticker-set-clone' : ''}`}
      aria-hidden={clone || undefined}
      style={{ display: 'inline-flex', flex: '0 0 auto', whiteSpace: 'nowrap' }}>
      {items.map((s, i) => (
        <span key={i} style={{ opacity: 0.85, paddingRight: 36 }}>{s}</span>
      ))}
    </div>
  );

  return (
    <div className="tape" style={{ padding: '8px 0', overflow: 'hidden' }}>
      <div style={{ display: 'flex', alignItems: 'center' }}>
        {/* Pinned status pill */}
        <span style={{
          flex: '0 0 auto',
          padding: '0 22px 0 28px',
          display: 'inline-flex', alignItems: 'center', gap: 8,
          borderRight: '1px solid rgba(255,255,255,0.15)',
        }}>
          <span style={{
            width: 8, height: 8, borderRadius: 999, display: 'inline-block',
            background: phase === 'idle' ? 'var(--accent-500)' : 'var(--forest-500)',
          }} className={phase === 'idle' ? '' : 'pulse-dot'}/>
          {statusLabel}
        </span>

        {/* Scrolling marquee */}
        <div className="ticker-viewport" style={{
          flex: 1, minWidth: 0, overflow: 'hidden',
          position: 'relative', paddingLeft: 22,
        }}>
          <div className="ticker-track" style={{ display: 'inline-flex', whiteSpace: 'nowrap' }}>
            {renderSet(false)}
            {renderSet(true)}
          </div>
          {/* Subtle right-edge fade so items don't get harshly clipped */}
          <div style={{
            position: 'absolute', top: 0, right: 0, bottom: 0, width: 24,
            background: 'linear-gradient(to right, transparent, var(--tm-navy-900))',
            pointerEvents: 'none',
          }}/>
        </div>
      </div>
      <style>{`
        @keyframes ticker-scroll {
          from { transform: translateX(0); }
          to   { transform: translateX(-50%); }
        }
        /* Desktop default: no scroll, hide the duplicate set (otherwise it
           would render twice as static visible text). !important is needed
           because the JSX sets display:inline-flex inline. */
        .ticker-set-clone { display: none !important; }
        @media (max-width: 920px) {
          .ticker-track {
            animation: ticker-scroll 22s linear infinite;
            will-change: transform;
          }
          .ticker-set-clone { display: inline-flex !important; }
        }
        .tape:hover .ticker-track { animation-play-state: paused; }
        @media (prefers-reduced-motion: reduce) {
          .ticker-track { animation: none !important; }
        }
      `}</style>
    </div>
  );
}

// ----------------------------- HERO -----------------------------
function Hero({ phase, isInk }) {
  return (
    <section style={{ padding: '32px 0 8px', maxWidth: 940 }}>
      <div className="mono-cap rule-eyebrow" style={{ color: 'var(--tm-cyan-600)', marginBottom: 18 }}>BY TECHMINDS · CANADA</div>
      <h1 className="digit" style={{ fontSize: 'clamp(40px, 6.2vw, 78px)', margin: 0, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1.02 }}>
        Test your <span className="wash">connection</span>.<br/>Get the real numbers.
      </h1>
      <p style={{ fontFamily: 'var(--font-sans)', fontSize: 17, lineHeight: 1.55, maxWidth: 600, marginTop: 18, color: 'var(--fg-2)' }}>
        Measure download, upload, latency and jitter against a TechMinds server near you. We'll tell you exactly what your line can and can't handle — in plain English.
      </p>
    </section>
  );
}

// ----------------------------- PHASE RIBBON -----------------------------
function PhaseRibbon({ phase }) {
  const steps = [
    { id: 'latency',  label: 'Latency' },
    { id: 'download', label: 'Download' },
    { id: 'upload',   label: 'Upload' },
  ];
  const order = ['idle', 'latency', 'download', 'upload', 'done'];
  const idx = order.indexOf(phase);
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 0, marginTop: 32, justifyContent: 'center' }}>
      {steps.map((s, i) => {
        const stepIdx = order.indexOf(s.id);
        const state = stepIdx < idx ? 'done' : stepIdx === idx ? 'active' : 'wait';
        const color = state === 'active' ? 'var(--accent-500)' : state === 'done' ? 'var(--brand-700)' : 'rgba(26,22,18,0.25)';
        return (
          <React.Fragment key={s.id}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 14px' }}>
              <span style={{ width: 8, height: 8, borderRadius: 999, background: color }} className={state === 'active' ? 'pulse-dot' : ''}/>
              <span className="mono-cap" style={{ opacity: state === 'wait' ? 0.4 : 0.9, color: state === 'active' ? 'var(--accent-600)' : 'inherit' }}>{s.label}</span>
            </div>
            {i < steps.length - 1 && <span style={{ flex: '0 0 28px', height: 1, background: 'rgba(26,22,18,0.14)' }}/>}
          </React.Fragment>
        );
      })}
    </div>
  );
}

// ----------------------------- FIELD REPORT -----------------------------
function FieldReport({ reading, reportNo, server, isp, isInk }) {
  const v = verdictFor(reading);
  return (
    <div className="card fade-up" style={{ padding: 32, marginTop: 24, position: 'relative', overflow: 'hidden' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 18, flexWrap: 'wrap', gap: 16 }}>
        <div>
          <div className="mono-cap" style={{ color: 'var(--tm-cyan-600)' }}>TEST RESULT</div>
          <h3 className="digit" style={{ fontSize: 38, margin: '4px 0 0', fontWeight: 700, letterSpacing: '-0.025em' }}>
            {v.headline}
          </h3>
          <p style={{ fontFamily: 'var(--font-sans)', fontSize: 15, marginTop: 8, maxWidth: 560,
            color: isInk ? 'rgba(252,250,244,0.78)' : 'var(--ink-600)' }}>{v.subline}</p>
        </div>
        <div style={{ textAlign: 'right' }}>
          <div className="mono-cap" style={{ opacity: 0.6 }}>№ {String(reportNo).padStart(3,'0')} · {reading.ts}</div>
          <div style={{ marginTop: 14, display: 'flex', alignItems: 'center', gap: 14, justifyContent: 'flex-end' }}>
            <div style={{ textAlign: 'right' }}>
              <div className="mono-cap" style={{ opacity: 0.6 }}>GRADE</div>
              <div className="digit" style={{ fontSize: 48, lineHeight: 1, color: v.grade.startsWith('A') ? 'var(--forest-500)' : v.grade === 'B' ? 'var(--brand-700)' : v.grade === 'C' ? 'var(--accent-500)' : 'var(--clay-500)' }}>{v.grade}</div>
            </div>
            <div className="stamp">verified</div>
          </div>
        </div>
      </div>

      <hr className="hr"/>
      <div style={{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: 32, padding: '20px 0' }} className="report-grid">
        <div>
          <div className="mono-cap" style={{ opacity: 0.6, marginBottom: 12 }}>What this connection comfortably handles</div>
          <ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 10 }}>
            {v.capabilities.map((c, i) => (
              <li key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, fontFamily: 'var(--font-sans)', fontSize: 14 }}>
                <span style={{ flex: '0 0 18px', marginTop: 3, color: c.test ? 'var(--forest-500)' : 'var(--ink-300)' }}>
                  {c.test ? (
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
                  ) : (
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
                  )}
                </span>
                <span style={{ flex: 1, opacity: c.test ? 1 : 0.55, textDecoration: c.test ? 'none' : 'line-through' }}>{c.label}</span>
                <span className="mono-cap" style={{ opacity: 0.5 }}>{c.need}</span>
              </li>
            ))}
          </ul>
        </div>
        <div>
          <div className="mono-cap" style={{ opacity: 0.6, marginBottom: 12 }}>The numbers</div>
          <table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-sans)', fontSize: 14 }}>
            <tbody>
              {[
                { k: 'Download',  v: `${fmt(reading.dl, reading.dl>=100?0:1)} Mbps` },
                { k: 'Upload',    v: `${fmt(reading.ul, reading.ul>=100?0:1)} Mbps` },
                { k: 'Latency',   v: `${fmt(reading.ping, 0)} ms` },
                { k: 'Jitter',    v: `${fmt(reading.jit, 1)} ms` },
                { k: 'Server',    v: `${server.city}` },
                { k: 'ISP',       v: isp },
              ].map((row,i) => (
                <tr key={i} style={{ borderBottom: '1px dashed rgba(26,22,18,0.14)' }}>
                  <td style={{ padding: '8px 0', opacity: 0.6 }}>{row.k}</td>
                  <td style={{ padding: '8px 0', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{row.v}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>

      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, paddingTop: 8 }}>
        <div className="mono-cap" style={{ color: 'var(--fg-3)' }}>TechMinds Speedtest · {server.city}</div>
      </div>

      <style>{`
        @media (max-width: 720px) {
          .report-grid { grid-template-columns: 1fr !important; }
        }
      `}</style>
    </div>
  );
}

// ----------------------------- SERVER CARD -----------------------------
function ServerCard({ server, isp, phase, isInk }) {
  return (
    <div className="card" style={{ padding: 20 }}>
      <div className="mono-cap" style={{ opacity: 0.6, marginBottom: 12 }}>TEST ENDPOINT</div>
      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
        <ServerMap server={server} isInk={isInk}/>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div className="digit" style={{ fontSize: 22 }}>{server.city}</div>
          <div style={{ fontFamily: 'var(--font-sans)', fontSize: 13, opacity: 0.7 }}>
            routed via {isp}
          </div>
        </div>
      </div>
      <div style={{ marginTop: 14, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
        <span className="chip is-active">{server.iata}</span>
      </div>
    </div>
  );
}

// little stylized map
function ServerMap({ server, isInk }) {
  // Approximate map pin by IATA code; defaults to centre-east if unknown so
  // the card never renders empty even before /api/info resolves.
  const POS_BY_IATA = {
    YYZ: [60, 58], YUL: [68, 54], YVR: [22, 50], YYC: [32, 52], ORD: [54, 62],
    JFK: [70, 58], LHR: [82, 42], FRA: [86, 44], NRT: [12, 50], SYD: [16, 76],
  };
  const pos = POS_BY_IATA[(server?.iata || '').toUpperCase()] || [60, 58];
  const stroke = isInk ? 'rgba(252,250,244,0.4)' : 'rgba(26,22,18,0.35)';
  const subtle = isInk ? 'rgba(252,250,244,0.06)' : 'rgba(26,22,18,0.05)';
  return (
    <div style={{ width: 110, height: 88, position: 'relative', borderRadius: 12, background: subtle, flex: '0 0 auto',
      backgroundImage: `linear-gradient(to right, ${stroke}11 1px, transparent 1px), linear-gradient(to bottom, ${stroke}11 1px, transparent 1px)`,
      backgroundSize: '14px 14px', overflow: 'hidden' }}>
      <svg viewBox="0 0 100 80" width="100%" height="100%" style={{ position: 'absolute', inset: 0 }}>
        {/* stylized north america outline */}
        <path d="M 8 24 Q 16 18 26 22 L 34 18 Q 44 14 56 16 L 70 14 Q 82 16 90 26 L 88 38 Q 84 48 78 54 L 72 64 Q 64 70 56 68 L 48 72 Q 40 70 34 66 L 24 60 Q 16 52 12 44 Z" fill="none" stroke={stroke} strokeWidth="0.8" strokeLinejoin="round"/>
        {/* pin */}
        <g transform={`translate(${pos[0]} ${pos[1]})`}>
          <circle r="11" fill="var(--accent-500)" opacity="0.18"/>
          <circle r="6"  fill="var(--accent-500)" opacity="0.32"/>
          <circle r="2.5" fill="var(--accent-500)"/>
        </g>
      </svg>
      <div className="mono-cap" style={{ position: 'absolute', bottom: 6, right: 8, fontSize: 9, opacity: 0.7 }}>{server.iata}</div>
    </div>
  );
}

// ----------------------------- WEATHER (NETWORK CONDITIONS) -----------------------------
function WeatherCard({ phase, live, netInfo, packetLoss, isInk }) {
  // All values derived from real measurements:
  //   - packetLoss: ratio of failed /api/ping requests during the latency phase
  //   - bufferbloat: jitter threshold heuristic (already real)
  //   - IPv6: from /api/info (server reports the address family it saw)
  //   - DNS lookup: PerformanceResourceTiming on the /api/info request
  const lossPctStr = packetLoss == null ? '—'
    : packetLoss === 0 ? '0.0%'
    : (packetLoss * 100).toFixed(1) + '%';
  const lossTone = packetLoss == null ? 'idle'
    : packetLoss < 0.005 ? 'good'
    : packetLoss < 0.02 ? 'warn'
    : 'bad';
  const ipv6Str = netInfo.ipv6 == null ? '—' : (netInfo.ipv6 ? 'Healthy' : 'IPv4 only');
  const ipv6Tone = netInfo.ipv6 == null ? 'idle' : (netInfo.ipv6 ? 'good' : 'warn');
  const dnsStr = netInfo.dnsMs == null ? '—'
    : netInfo.dnsMs < 1 ? '<1 ms'
    : Math.round(netInfo.dnsMs) + ' ms';
  const dnsTone = netInfo.dnsMs == null ? 'idle' : (netInfo.dnsMs < 50 ? 'good' : 'warn');
  const bbTone = live.jit === 0 ? 'idle' : (live.jit > 4 ? 'warn' : 'good');
  const items = [
    { k: 'Packet loss', v: lossPctStr, tone: lossTone },
    { k: 'Bufferbloat', v: live.jit === 0 ? '—' : (live.jit > 4 ? 'Moderate' : 'None'), tone: bbTone },
    { k: 'IPv6',        v: ipv6Str, tone: ipv6Tone },
    { k: 'DNS lookup',  v: dnsStr,  tone: dnsTone },
  ];
  const toneColor = (tn) => tn === 'good' ? 'var(--forest-500)'
    : tn === 'warn' ? 'var(--accent-500)'
    : tn === 'bad' ? 'var(--clay-500)'
    : 'rgba(128,128,128,0.5)';
  return (
    <div className="card" style={{ padding: 20 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
        <div className="mono-cap" style={{ opacity: 0.6 }}>NETWORK WEATHER</div>
        <div className="mono-cap" style={{ opacity: 0.4 }}>now</div>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 12 }}>
        {items.map((it, i) => (
          <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, fontFamily: 'var(--font-sans)', fontSize: 13.5 }}>
            <span style={{ width: 6, height: 6, borderRadius: 999, background: toneColor(it.tone), flex: '0 0 auto' }}/>
            <span style={{ opacity: 0.65 }}>{it.k}</span>
            <span style={{ flex: 1, height: 1, background: 'rgba(26,22,18,0.10)', borderBottom: '1px dashed currentColor', opacity: 0.18 }}/>
            <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}>{it.v}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

// ----------------------------- HISTORY -----------------------------
function HistorySection({ history, isInk }) {
  return (
    <section style={{ marginTop: 64 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 18, gap: 16, flexWrap: 'wrap' }}>
        <div>
          <div className="mono-cap rule-eyebrow" style={{ opacity: 0.6 }}>YOUR LEDGER</div>
          <h2 className="digit" style={{ fontSize: 'clamp(28px, 3.4vw, 42px)', margin: '6px 0 0', fontWeight: 700, letterSpacing: '-0.025em' }}>
            Your recent tests
          </h2>
        </div>
        <button className="btn btn-md btn-ghost">View full ledger {I.arrow}</button>
      </div>

      <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
        <div style={{ display: 'grid', gridTemplateColumns: '60px 1.4fr 1fr 1fr 1fr 1fr 80px', gap: 0,
          padding: '12px 24px', borderBottom: '1px solid rgba(26,22,18,0.08)' }} className="hist-head">
          {['№','When','↓ Down','↑ Up','Lat','Jit','Grade'].map((h,i) => (
            <div key={i} className="mono-cap" style={{ opacity: 0.5 }}>{h}</div>
          ))}
        </div>
        {history.map((r, idx) => (
          <div key={r.id} style={{ display: 'grid', gridTemplateColumns: '60px 1.4fr 1fr 1fr 1fr 1fr 80px', gap: 0,
            padding: '14px 24px', borderBottom: idx === history.length - 1 ? 'none' : '1px dashed rgba(26,22,18,0.10)',
            alignItems: 'center', fontFamily: 'var(--font-sans)', fontSize: 14 }} className="hist-row">
            <div className="mono-cap" style={{ opacity: 0.55 }}>{String(r.id).padStart(3,'0')}</div>
            <div>
              <div style={{ fontWeight: 500 }}>{r.ts}</div>
              <div className="mono-cap" style={{ opacity: 0.55, marginTop: 2 }}>{r.server}</div>
            </div>
            <div style={{ fontVariantNumeric: 'tabular-nums' }}>{fmt(r.dl, r.dl>=100?0:1)} <span className="mono-cap" style={{ opacity: 0.5 }}>Mbps</span></div>
            <div style={{ fontVariantNumeric: 'tabular-nums' }}>{fmt(r.ul, r.ul>=100?0:1)} <span className="mono-cap" style={{ opacity: 0.5 }}>Mbps</span></div>
            <div style={{ fontVariantNumeric: 'tabular-nums' }}>{fmt(r.ping, 0)} <span className="mono-cap" style={{ opacity: 0.5 }}>ms</span></div>
            <div style={{ fontVariantNumeric: 'tabular-nums' }}>{fmt(r.jit, 1)} <span className="mono-cap" style={{ opacity: 0.5 }}>ms</span></div>
            <div>
              <span className="digit" style={{ fontSize: 22, color: r.grade.startsWith('A') ? 'var(--forest-500)' : r.grade === 'B' ? 'var(--brand-700)' : r.grade === 'C' ? 'var(--accent-500)' : 'var(--clay-500)' }}>{r.grade}</span>
            </div>
          </div>
        ))}
      </div>

      <style>{`
        @media (max-width: 720px) {
          .hist-head { grid-template-columns: 50px 1.2fr 1fr 1fr 60px !important; }
          .hist-row  { grid-template-columns: 50px 1.2fr 1fr 1fr 60px !important; }
          .hist-head > :nth-child(5), .hist-head > :nth-child(6),
          .hist-row > :nth-child(5),  .hist-row > :nth-child(6) { display: none; }
        }
      `}</style>
    </section>
  );
}

// ----------------------------- FAQ -----------------------------
function FAQ({ isInk }) {
  const qs = [
    { q: 'Why does my speed not match my plan?',
      a: 'Wired speeds usually match the plan; Wi-Fi often does not. Distance to the router, the band (2.4 vs 5 vs 6 GHz), the device’s radio, and neighbouring networks all cost throughput. Run the test on Ethernet to see what the line itself is doing.' },
    { q: 'What is jitter, in plain language?',
      a: 'Variation in latency between successive packets. Voice and video calls feel “glitchy” when jitter creeps above 5 ms or so, even if average latency looks fine.' },
    { q: 'Do you sell or share results?',
      a: 'No. Results live in your browser only. You can copy a link to share a single report, but nothing is sent to ad networks, analytics, or third parties.' },
    { q: 'Why pick the closest server?',
      a: 'A nearby server measures your local link, not the public internet at large. For a true reading of your ISP’s last mile, that’s usually what you want.' },
  ];
  const [open, setOpen] = useState(0);
  return (
    <section style={{ marginTop: 80 }}>
      <div className="mono-cap rule-eyebrow" style={{ opacity: 0.6 }}>READ MORE</div>
      <h2 className="digit" style={{ fontSize: 'clamp(28px, 3.4vw, 42px)', margin: '6px 0 24px', fontWeight: 700, letterSpacing: '-0.025em' }}>
        Frequently asked questions
      </h2>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid rgba(26,22,18,0.10)' }}>
        {qs.map((it, i) => (
          <div key={i} style={{ borderBottom: '1px solid rgba(26,22,18,0.10)' }}>
            <button onClick={() => setOpen(open === i ? -1 : i)}
              style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
                padding: '20px 4px', background: 'transparent', border: 'none', cursor: 'pointer', color: 'inherit', textAlign: 'left' }}>
              <span style={{ fontFamily: 'var(--font-display)', fontSize: 22, fontWeight: 500, letterSpacing: '-0.02em' }}>{it.q}</span>
              <span className="mono-cap" style={{ opacity: 0.5 }}>{open === i ? '–' : '+'}</span>
            </button>
            {open === i && (
              <div style={{ padding: '0 4px 22px', maxWidth: 760 }}>
                <p style={{ margin: 0, fontSize: 15, lineHeight: 1.65, color: isInk ? 'rgba(252,250,244,0.78)' : 'var(--ink-600)' }}>{it.a}</p>
              </div>
            )}
          </div>
        ))}
      </div>
    </section>
  );
}

// ----------------------------- FOOTER -----------------------------
function Footer({ isInk }) {
  return (
    <footer style={{ background: isInk ? 'rgba(252,250,244,0.04)' : 'var(--ink-900)', color: 'var(--cream-100)', marginTop: 60 }}>
      <div style={{ maxWidth: 1240, margin: '0 auto', padding: '40px 28px 28px', display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 24, alignItems: 'flex-end' }}>
        <div>
          <div style={{ fontFamily: 'var(--font-sans)', fontWeight: 700, fontSize: 26, letterSpacing: '-0.02em' }}>
            <span style={{ color: '#fff' }}>Tech</span><span style={{ color: 'var(--tm-cyan-400)' }}>Minds</span>
          </div>
          <p style={{ marginTop: 10, fontSize: 13, maxWidth: 380, opacity: 0.7, lineHeight: 1.55 }}>
            Reliable internet service provider in Canada. The Speedtest is free to use — no account required, no tracking, no ads.
          </p>
        </div>
        <div style={{ display: 'flex', gap: 36, fontFamily: 'var(--font-sans)', fontSize: 13 }}>
          <div><div className="mono-cap" style={{ opacity: 0.55, marginBottom: 8 }}>TOOLS</div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 6, opacity: 0.85 }}>
              <a href="#" style={{ color: 'inherit' }}>Speedtest</a>
              <a href="#" style={{ color: 'inherit' }}>Latency monitor</a>
              <a href="#" style={{ color: 'inherit' }}>DNS check</a>
            </div>
          </div>
          <div><div className="mono-cap" style={{ opacity: 0.55, marginBottom: 8 }}>COMPANY</div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 6, opacity: 0.85 }}>
              <a href="#" style={{ color: 'inherit' }}>About</a>
              <a href="#" style={{ color: 'inherit' }}>Privacy</a>
              <a href="#" style={{ color: 'inherit' }}>Contact</a>
            </div>
          </div>
        </div>
      </div>
      <div style={{ borderTop: '1px solid rgba(252,250,244,0.10)' }}>
        <div style={{ maxWidth: 1240, margin: '0 auto', padding: '14px 28px', display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
          <span className="mono-cap" style={{ opacity: 0.55 }}>© {new Date().getFullYear()} TECHMINDS INC · ALL RIGHTS RESERVED</span>
          <span className="mono-cap" style={{ opacity: 0.55 }}>140D-2967 DUNDAS ST W · TORONTO, ON</span>
        </div>
      </div>
    </footer>
  );
}

// ----------------------------- helpers -----------------------------
function nowStamp() {
  const d = new Date();
  const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  return `${months[d.getMonth()]} ${d.getDate()}, ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}

// ----------------------------- mount -----------------------------
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
