import { useEffect, useMemo, useState } from 'react';
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
import { GetLogStats, GetContestRuns, GetOperators } from '../../wailsjs/go/main/App';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';

// ─────────────────────────────────────────────────────────────────────────────
// Statistics dashboard.
//
// Colour rules this file obeys (they are not stylistic — they are what keeps the
// charts readable and colour-blind-safe):
//
//  • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
//    encodes identity, never a magnitude the bar's length already shows.
//  • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
//    the categories ARE the subject (the continent donut). The order is the
//    colour-blind-safety mechanism, so it is never shuffled or cycled.
//  • The donut is for part-to-whole read at a glance. It is NOT used for modes:
//    CW and SSB are nearly tied, and a donut hides exactly the comparison that
//    makes them interesting. Close values belong in bars.
//  • Every value is direct-labelled, so nothing is reachable only by hovering, and
//    a table view gives the WCAG-clean twin.
// ─────────────────────────────────────────────────────────────────────────────

type Bucket = { key: string; count: number };
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
type Gap = { start: string; end: string; minutes: number };
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
type Stats = {
  total: number; unique_calls: number; entities: number; continents: number;
  first_qso: string; last_qso: string;
  last_new_dxcc: string; last_new_dxcc_num: number; last_new_dxcc_call: string; last_new_dxcc_date: string;
  confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
  by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
  by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
  by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
  // Chronological series across the SELECTED period, one per bucket size; empty
  // when that bucket would be too fine for the period (see the Go side).
  by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
  by_hour_of_day: Bucket[]; by_weekday: Bucket[];
  // Period / contest metrics.
  window_start: string; window_end: string; window_hours: number;
  avg_per_hour: number; avg_per_active: number;
  on_air_minutes: number; off_air_minutes: number;
  peak_hour_key: string; peak_hour_count: number; best_60: number;
  gaps: Gap[]; rate: Bucket[];
  rate_ops: string[]; rate_by_op: number[][];
};

// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);

const CONTINENT_NAME: Record<string, string> = {
  EU: 'Europe', NA: 'North America', SA: 'South America',
  AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
};

const nf = (n: number) => n.toLocaleString('en-US');

// ── Shell ────────────────────────────────────────────────────────────────────

function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
  return (
    <section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
      <header className="mb-3 shrink-0">
        <h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
          <span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
          {title}
        </h3>
        {sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
      </header>
      {children}
    </section>
  );
}

// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
// carries an accent (a categorical chart hue or a semantic token): a soft tint
// wash + a left accent bar give it a colourful identity, while the number itself
// stays in high-contrast foreground ink so it reads on every theme.
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
  return (
    <div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
      <div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
      <div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
      <div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
      <div className="relative">
        <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
        {/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
        <p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
        {sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
      </div>
    </div>
  );
}

// ── Horizontal bars (long category names: modes, operators, entities) ─────────
// One series → one hue. The value is direct-labelled, so no reader ever depends
// on a tooltip to get a number.

function HBars({ data, max, empty, share, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
  // max is a display cap for long tails (top entities). Where EVERY row matters —
  // the operators of a multi-op — it is deliberately not set: a capped chart would
  // silently drop the 9th operator, and "who worked what" is the whole question.
  const top = max ? data.slice(0, max) : data;
  const peak = Math.max(1, ...top.map((d) => d.count));
  const total = data.reduce((s, d) => s + d.count, 0);
  if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
  return (
    <div className="flex flex-col gap-1.5 min-w-0">
      {top.map((d, i) => (
        <div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
          <span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
          <div className="flex-1 min-w-0 h-[14px] flex items-center">
            <div
              className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
              style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
            />
          </div>
          <span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
          {share && (
            <span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
              {total ? ((d.count / total) * 100).toFixed(0) : 0}%
            </span>
          )}
        </div>
      ))}
    </div>
  );
}

// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
// information.

function VBars({ data, empty, height = 150, showValues, colorful }: { data: Bucket[]; empty: string; height?: number; showValues?: boolean; colorful?: boolean }) {
  const peak = Math.max(1, ...data.map((d) => d.count));
  if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
  // Thin the x labels once the bars get narrow. A label under EVERY one of 48
  // hourly bars is unreadable mush — the axis exists to orient, not to enumerate
  // (the value is in the tooltip, and every number is in the table view).
  const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
  return (
    // The container includes the x-axis band — a fixed height that excludes it is
    // how cards end up with a tiny nested scrollbar.
    <div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
      {data.map((d, i) => (
        <div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
          title={`${d.key} — ${nf(d.count)}`}>
          <span className={cn('text-[9px] font-semibold mb-0.5 tabular-nums whitespace-nowrap',
            showValues ? 'text-foreground' : 'text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity')}>
            {nf(d.count)}
          </span>
          <div
            className="w-full rounded-t-[4px] transition-[height] duration-300"
            style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : 'var(--chart-1)' }}
          />
          <span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
            {i % every === 0 ? d.key : ''}
          </span>
        </div>
      ))}
    </div>
  );
}

// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
// categories are the subject → categorical colour in a fixed order, matching the
// mode colours used elsewhere (CW gold, phone green, data blue).
const BAND_SPLIT = [
  { key: 'cw', label: 'CW', color: 'var(--chart-3)' },
  { key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
  { key: 'data', label: 'Data', color: 'var(--chart-1)' },
] as const;
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
  if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
  const peak = Math.max(1, ...data.map((d) => d.total));
  return (
    <div>
      <div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
        {data.map((d) => (
          <div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
            title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)}  (${nf(d.total)})`}>
            <span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
            <div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
              style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
              {BAND_SPLIT.map((s) => {
                const v = d[s.key];
                if (!v) return null;
                return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
              })}
            </div>
            <span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
          </div>
        ))}
      </div>
      <div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
        {BAND_SPLIT.map((s) => (
          <span key={s.key} className="inline-flex items-center gap-1">
            <span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
          </span>
        ))}
      </div>
    </div>
  );
}

// ── Contest rate, stacked by operator ────────────────────────────────────────
// The categories (the operators) ARE the subject, so this is categorical colour,
// in the FIXED order the backend sends (busiest first). An operator therefore
// keeps the same hue everywhere on the page — a chart that repaints its series
// when the filter changes is a chart nobody can trust.

function RateStack({ rate, ops, byOp, empty, height = 130 }: {
  rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
}) {
  const [hov, setHov] = useState<number | null>(null);
  if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
  const peak = Math.max(1, ...rate.map((d) => d.count));
  const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
  const single = ops.length <= 1; // one operator → one hue; a legend would be noise

  return (
    <div className="min-w-0">
      <div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
        {rate.map((d, i) => (
          <div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
            onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
            {/* The column is the hour's total; the segments are who made it. */}
            <div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
              style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
              {(byOp[i] ?? []).map((n, o) => n > 0 && (
                <div key={o} style={{
                  height: `${(n / Math.max(1, d.count)) * 100}%`,
                  background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
                }} />
              ))}
            </div>
            <span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
              {i % every === 0 ? d.key : ''}
            </span>
          </div>
        ))}
      </div>

      {/* Hover read-out: the hour, its total, and the split. Values are also in the
          rate sheet below, so nothing is reachable by hover alone. */}
      <p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
        {hov !== null && (
          <>
            <span className="text-foreground font-medium">{rate[hov].key}</span>
            {' · '}{nf(rate[hov].count)} QSO
            {!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
              <span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}>■</span> {ops[o]} {n}</span>
            ))}
          </>
        )}
      </p>

      {!single && (
        <div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
          {ops.map((op, o) => (
            <span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
              <span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
              {op}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

// ── The rate sheet: hour × operator, with the hour total ─────────────────────
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
// read rate sheets as tables, and every value here is also the one the chart draws.

function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
  if (rate.length === 0) return null;
  const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
  const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
  const grand = rate.reduce((s, d) => s + d.count, 0);

  return (
    <div className="overflow-auto max-h-[260px] rounded-md border border-border">
      <table className="w-full text-[11px] tabular-nums">
        <thead className="sticky top-0 bg-muted/60 backdrop-blur">
          <tr className="text-muted-foreground">
            <th className="text-left font-medium px-2 py-1">UTC</th>
            {ops.map((op, o) => (
              <th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
                <span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
                  style={{ background: `var(--chart-${(o % 8) + 1})` }} />
                {op}
              </th>
            ))}
            <th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
          </tr>
        </thead>
        <tbody>
          {shown.map(({ d, i }) => (
            <tr key={d.key} className="border-t border-border/50">
              <td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
              {ops.map((_, o) => (
                <td key={o} className="px-2 py-0.5 text-right">
                  {byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
                </td>
              ))}
              <td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
            </tr>
          ))}
        </tbody>
        <tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
          <tr className="border-t border-border font-semibold">
            <td className="px-2 py-1">Total</td>
            {totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
            <td className="px-2 py-1 text-right">{nf(grand)}</td>
          </tr>
        </tfoot>
      </table>
    </div>
  );
}

// ── Activity over time (single series → area, one hue) ────────────────────────
// Only the PEAK and the LAST point are direct-labelled. A number on every point
// is chaos and goes unread.

function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
  const [hover, setHover] = useState<number | null>(null);
  if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;

  const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
  const x = (i: number) => (i / (data.length - 1)) * W;
  const y = (v: number) => H - (v / peak) * H;
  const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
  const area = `${line} L${W},${H} L0,${H} Z`;
  const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
  const h = hover ?? -1;

  return (
    <div className="min-w-0">
      <div className="relative" style={{ height }}>
        <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
          onMouseLeave={() => setHover(null)}>
          {/* Recessive hairline grid — solid, never dashed. */}
          {[0, 0.5, 1].map((f) => (
            <line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
          ))}
          <defs>
            <linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
              <stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
            </linearGradient>
          </defs>
          <path d={area} fill="url(#statsArea)" />
          <path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
            strokeLinejoin="round" strokeLinecap="round" />
          {h >= 0 && (
            <>
              <line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
              {/* 2px surface ring so the marker reads on top of the area. */}
              <circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
            </>
          )}
          {/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
          {data.map((_, i) => (
            <rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
              fill="transparent" onMouseEnter={() => setHover(i)} />
          ))}
        </svg>
        {h >= 0 && (
          <div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
            style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
            <span className="text-muted-foreground">{data[h].key}</span>{' '}
            <span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
          </div>
        )}
      </div>
      <div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
        <span>{data[0].key}</span>
        <span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
        <span>{data[data.length - 1].key}</span>
      </div>
    </div>
  );
}

// ── Part-to-whole (continents) → the donut ───────────────────────────────────
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
// few segments, one share obviously dominant. It is the WRONG form for comparing
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
// donut would hide precisely the fact that makes them interesting).
//
// The categories ARE the subject here, so this is the one chart using the
// categorical slots — in FIXED order, with a legend and printed values, because
// the dark palette sits in the CVD floor band where labels are mandatory.

function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
  const [hov, setHov] = useState<number | null>(null);
  const total = data.reduce((s, d) => s + d.count, 0);
  if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;

  const slots = data.slice(0, 8); // never generate a 9th hue
  const R = 58, SW = 22, C = 2 * Math.PI * R;
  let acc = 0;
  const arcs = slots.map((d, i) => {
    const frac = d.count / total;
    // A 2px surface gap between segments — the correct separator, not a border.
    const len = Math.max(0, frac * C - 2);
    const a = { key: d.key, i, len, off: -acc * C, frac };
    acc += frac;
    return a;
  });

  return (
    <div className="flex items-center gap-4 min-w-0">
      <div className="relative shrink-0">
        <svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
          {arcs.map((a) => (
            <circle key={a.key} cx={80} cy={80} r={R} fill="none"
              stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
              strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
              className="transition-[stroke-width] duration-150 cursor-default"
              onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
          ))}
        </svg>
        {/* The hole is not decoration — it carries the total the slices add up to. */}
        <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
          {hov === null ? (
            <>
              <span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
              <span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
            </>
          ) : (
            <>
              <span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
              <span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
                {CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
              </span>
            </>
          )}
        </div>
      </div>
      <ul className="flex-1 min-w-0 flex flex-col gap-1">
        {slots.map((d, i) => (
          <li key={d.key}
            onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
            className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
              hov === i && 'bg-muted')}>
            {/* The swatch carries identity; the text stays in ink tokens. */}
            <span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
            <span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
            <span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
            <span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
              {((d.count / total) * 100).toFixed(0)}%
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────

function Meter({ label, value, total }: { label: string; value: number; total: number }) {
  const pct = total > 0 ? (value / total) * 100 : 0;
  return (
    <div className="min-w-0">
      <div className="flex items-baseline justify-between gap-2 mb-1">
        <span className="text-[11px] text-muted-foreground truncate">{label}</span>
        <span className="text-[11px] tabular-nums">
          <span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
          <span className="text-muted-foreground"> · {nf(value)}</span>
        </span>
      </div>
      {/* Same-ramp track: the meter and its track are one hue, not two. */}
      <div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
        <div className="h-full rounded-full transition-[width] duration-500"
          style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
      </div>
    </div>
  );
}

// ── Period ───────────────────────────────────────────────────────────────────
// ONE filter row above everything it scopes — never a control inside a card, or
// the charts would each show a different slice of time.

type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';

const iso = (d: Date) => d.toISOString().slice(0, 10);

// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
function periodRange(p: Period, from: string, to: string): [string, string] {
  const now = new Date();
  switch (p) {
    case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
    case 'y12': {
      const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
      return [iso(d), ''];
    }
    case 'd30': {
      const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
      return [iso(d), ''];
    }
    case 'custom': return [from, to];
    default: return ['', ''];
  }
}

// ActivityCard — the "activity over time" chart with a granularity selector. Its
// own state, module-scoped so a parent re-render doesn't reset the choice. The
// timeline is the chronological month series; day/week/month/year are the cyclical
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
// panel — not a window of its own. It used to be four fixed rolling windows
// anchored on the newest QSO, which ignored the period filter entirely: picking
// a year up top left this chart showing the last 7 days.
const ACT_GRAN = [
  { key: 'day', tkey: 'stats.granDay' },
  { key: 'week', tkey: 'stats.granWeek' },
  { key: 'month', tkey: 'stats.granMonth' },
  { key: 'year', tkey: 'stats.granYear' },
] as const;
type Gran = typeof ACT_GRAN[number]['key'];
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };

function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
  const [g, setG] = useState<Gran | 'auto'>('auto');
  const seriesFor = (k: Gran): Bucket[] => (
    k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
  );

  // Auto picks the finest bucket that stays readable: the backend leaves a
  // series empty when it would be too fine for the period, so "the first
  // non-empty one" is exactly the right rule and needs no thresholds here.
  const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
  // A bucket the user forces but that is too fine for the period falls back to
  // the next coarser one rather than showing an empty chart.
  let shown: Gran = g === 'auto' ? auto : g;
  while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
  const series = seriesFor(shown);
  const steppedUp = g !== 'auto' && shown !== g;

  return (
    <Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
      <div className="mb-2 flex flex-wrap gap-1 items-center">
        <button type="button" onClick={() => setG('auto')}
          className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
            g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
          {t('stats.granAuto')}
        </button>
        {ACT_GRAN.map((o) => (
          <button key={o.key} type="button" onClick={() => setG(o.key)}
            className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
              g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
            {t(o.tkey)}
          </button>
        ))}
        {steppedUp && (
          <span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
        )}
      </div>
      {/* Many buckets read better as a filled trend than as a forest of bars. */}
      {series.length > 60
        ? <AreaTrend data={series} empty={empty} />
        : <VBars data={series} empty={empty} showValues height={160} />}
    </Card>
  );
}

// RhythmCard — WHEN the operator is on the air over the selected period, which is
// a different question from "how much lately" and so gets its own card. The hour
// histogram used to cover a single day, too little to read anything from.
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
  const [g, setG] = useState<'hour' | 'weekday'>('hour');
  return (
    <Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
      <div className="mb-2 flex flex-wrap gap-1">
        {([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
          <button key={k} type="button" onClick={() => setG(k)}
            className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
              g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
            {t(tk)}
          </button>
        ))}
      </div>
      <VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
    </Card>
  );
}

// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────

function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
  const total = data.reduce((s, d) => s + d.count, 0);
  return (
    <div className="rounded-lg border border-border bg-card overflow-hidden">
      <p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
      <table className="w-full text-[11px]">
        <tbody>
          {data.map((d) => (
            <tr key={d.key} className="border-b border-border/50 last:border-0">
              <td className="px-3 py-1 truncate">{d.key}</td>
              <td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
              <td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
                {total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
              </td>
            </tr>
          ))}
          {data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
        </tbody>
      </table>
    </div>
  );
}

// ── Panel ────────────────────────────────────────────────────────────────────

export function StatsPanel() {
  const { t } = useI18n();
  const [stats, setStats] = useState<Stats | null>(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');
  const [view, setView] = useState<'charts' | 'table'>('charts');
  const [period, setPeriod] = useState<Period>('all');
  const [from, setFrom] = useState('');
  const [to, setTo] = useState('');
  // Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
  // that contest AND lets the window derive from its own span — no date typing.
  const [runs, setRuns] = useState<ContestRun[]>([]);
  const [contest, setContest] = useState('');
  // Operator picker: '' = all operators, "—" = station owner (empty OPERATOR).
  const [operators, setOperators] = useState<string[]>([]);
  const [operator, setOperator] = useState('');

  useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
  useEffect(() => { GetOperators().then((o: any) => setOperators((o ?? []) as string[])).catch(() => {}); }, []);

  const load = async (p: Period = period, f = from, t2 = to, c = contest, op = operator) => {
    // A contest defines its own window (its first→last QSO), so we send no dates
    // with it — the backend derives them. Sending a period as well would be two
    // filters fighting over the same axis.
    const [cid, cyr] = c ? c.split('|') : ['', '0'];
    const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
    setBusy(true); setErr('');
    try {
      const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0, op)) as any;
      // Harden the boundary: a Go nil slice arrives as JSON null, and a single
      // .length on null unmounts the whole React tree — a white screen. Normalise
      // once, here, rather than guarding at every use site and missing one.
      const arr = (v: any) => (Array.isArray(v) ? v : []);
      setStats({
        ...raw,
        by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
        by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
        top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
        by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
        by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
        by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
        rate: arr(raw.rate), gaps: arr(raw.gaps),
        rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
      } as Stats);
    }
    catch (e: any) { setErr(String(e?.message ?? e)); }
    finally { setBusy(false); }
  };
  // Re-run whenever the filter changes. A custom range only fires once BOTH ends
  // are set — otherwise every keystroke in the date box would re-scan the log.
  useEffect(() => {
    if (!contest && period === 'custom' && !(from && to)) return;
    load(period, from, to, contest, operator);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [period, from, to, contest, operator]);

  // The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
  // Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
  // between two ordinary evenings — numbers that are true and completely useless.
  // So: show it for a picked contest, or for a hand-set range short enough to be a
  // real operating session (which is exactly when an hourly rate chart exists).
  const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);

  // "last new: France - F5ABC - 2019-03-01": the entity most recently added to
  // the log, and the contact that added it. Empty when no dated entity exists.
  const lastNew = useMemo(() => {
    if (!stats?.last_new_dxcc_date) return '';
    const day = stats.last_new_dxcc_date.slice(0, 10);
    const who = stats.last_new_dxcc_call ? ` · ${stats.last_new_dxcc_call}` : '';
    const what = stats.last_new_dxcc || String(stats.last_new_dxcc_num || '');
    return `${t('stats.lastNewDxcc')}: ${what}${who} · ${day}`;
  }, [stats, t]);

  const span = useMemo(() => {
    if (!stats?.first_qso) return '';
    const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
    return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
  }, [stats]);

  if (busy && !stats) {
    return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
      <Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
    </div>;
  }
  if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
  if (!stats) return null;

  const empty = t('stats.noData');

  return (
    <div className="flex-1 min-h-0 overflow-auto p-3">
      {/* ONE filter/action row above everything it scopes — never per-card controls. */}
      <div className="flex items-center flex-wrap gap-2 mb-3">
        <h2 className="text-sm font-semibold">{t('stats.title')}</h2>
        {span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}

        {/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
            never from a static list — so it can't offer a contest you never entered.
            Choosing one supersedes the period: it brings its own window. */}
        <select value={contest} onChange={(e) => setContest(e.target.value)}
          className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
          <option value="">{t('stats.noContest')}</option>
          {runs.map((r) => (
            <option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
              {r.id} {r.year} — {nf(r.count)} QSO
            </option>
          ))}
        </select>

        {/* Operator picker — narrows every stat (totals, DXCC, top countries,
            continent split, rate) to one operator from the log. "—" = the
            station owner (QSOs logged with no OPERATOR set). */}
        {operators.length > 1 && (
          <select value={operator} onChange={(e) => setOperator(e.target.value)}
            className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[180px]"
            title={t('stats.operatorTip')}>
            <option value="">{t('stats.allOperators')}</option>
            {operators.map((op) => (
              <option key={op} value={op}>{op === '—' ? t('stats.stationOwner') : op}</option>
            ))}
          </select>
        )}

        <div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
          {([
            ['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
            ['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
          ] as [Period, string][]).map(([p, label], i) => (
            <button key={p} onClick={() => setPeriod(p)}
              className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
                period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
              {label}
            </button>
          ))}
        </div>
        {!contest && period === 'custom' && (
          <div className="inline-flex items-center gap-1">
            <input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
              className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
            <span className="text-xs text-muted-foreground">→</span>
            <input type="date" value={to} onChange={(e) => setTo(e.target.value)}
              className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
          </div>
        )}

        <div className="flex-1" />
        <div className="inline-flex rounded-md border border-border overflow-hidden">
          <button onClick={() => setView('charts')}
            className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
            <BarChart3 className="size-3.5" />{t('stats.charts')}
          </button>
          <button onClick={() => setView('table')}
            className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
            <Table2 className="size-3.5" />{t('stats.table')}
          </button>
        </div>
        <button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
          className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
          <RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
        </button>
      </div>

      {/* Headline figures: stat tiles, not a grouped bar chart. */}
      <div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
        <StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
        <StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
        {/* The last one ADDED rides on the entities tile: it is the same
            subject, and a tile of its own would push the five headline figures
            onto two rows for one line of text. */}
        <StatTile label={t('stats.entities')} value={nf(stats.entities)}
          sub={lastNew || "DXCC"} accent="var(--chart-5)" />
        <StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
        <StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
          sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
      </div>

      {/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
          seventeen years is noise; across a contest weekend it IS the result. */}
      {windowed && stats.total > 0 && (
        <div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
          <Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
            <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
              {/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
              <div title={t('stats.avgWindowTip')}>
                <p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
                <p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
              </div>
              <div title={t('stats.avgActiveTip')}>
                <p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
                <p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
              </div>
              <div title={t('stats.best60Tip')}>
                <p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
                <p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
              </div>
              {/* On-air + off-air = the window, by construction. The first version
                  counted "clock hours containing a QSO", which gave 39 h on air AND
                  16 h off air inside a 45 h contest — and rightly wasn't believed. */}
              <div title={t('stats.onAirTip')}>
                <p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
                <p className="text-[22px] leading-none font-semibold mt-1">
                  {dur(stats.on_air_minutes)}
                  <span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
                </p>
              </div>
            </div>
            {stats.rate.length > 1
              ? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
              : <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
          </Card>

          <Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
            {stats.gaps.length === 0 ? (
              <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
            ) : (
              <ul className="flex flex-col gap-1 min-w-0">
                {stats.gaps.map((g, i) => (
                  <li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
                    <span className="tabular-nums text-muted-foreground truncate">
                      {g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
                    </span>
                    <span className="flex-1 h-[6px] rounded-full min-w-0"
                      style={{
                        background: 'var(--chart-seq-1)',
                      }}>
                      <span className="block h-full rounded-full"
                        style={{
                          width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
                          background: 'var(--chart-1)',
                        }} />
                    </span>
                    <span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
                  </li>
                ))}
              </ul>
            )}
          </Card>

          {/* The rate sheet: exact numbers, many of them, hour by hour and operator
              by operator. That is a table's job, not a chart's — and it's how
              contesters actually read a run. Every value here is also what the
              stacked chart above draws, so the two can never disagree. */}
          {stats.rate.length > 1 && (
            <Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
              <RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
            </Card>
          )}
        </div>
      )}

      {view === 'table' ? (
        <div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
          <BucketTable title={t('stats.byBand')} data={stats.by_band} />
          <BucketTable title={t('stats.byMode')} data={stats.by_mode} />
          <BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
          <BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
          <BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
          <BucketTable title={t('stats.byYear')} data={stats.by_year} />
          <BucketTable title={t('stats.byStation')} data={stats.by_station} />
        </div>
      ) : (
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
          <Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
            <StackedBandBars data={stats.by_band_category} empty={empty} />
          </Card>
          <Card title={t('stats.byMode')} accent="var(--chart-2)">
            <HBars data={stats.by_mode} max={8} empty={empty} colorful />
          </Card>

          <ActivityCard stats={stats} t={t} empty={empty} />
          <RhythmCard stats={stats} t={t} empty={empty} />

          {/* EVERY operator, never a top-N: on a multi-op contest the point is who
              worked what, and a cap would quietly delete the 9th operator. Scrolls
              instead of truncating. */}
          <Card title={t('stats.byOperator')} accent="var(--chart-5)">
            <div className="max-h-[240px] overflow-auto pr-1 min-w-0">
              <HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
            </div>
          </Card>
          <Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
            <Donut data={stats.by_continent} empty={empty} />
          </Card>

          <Card title={t('stats.topEntities')} accent="var(--chart-4)">
            <HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
          </Card>
          <div className="flex flex-col gap-2.5 min-w-0">
            <Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
              <div className="flex flex-col gap-3 justify-center flex-1">
                <Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
                <Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
                <Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
              </div>
            </Card>
            <Card title={t('stats.byStation')} accent="var(--chart-8)">
              <div className="max-h-[140px] overflow-auto pr-1 min-w-0">
                <HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
              </div>
            </Card>
          </div>

        </div>
      )}
    </div>
  );
}
