import { useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Loader2, RefreshCw } from 'lucide-react';
import { GridSquares } from '../../wailsjs/go/main/App';
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
import { writeUiPref } from '@/lib/uiPref';

// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
//
// A square, not a marker. The question this answers is "where have I been
// heard", and the shape of the answer is an area: a wall of squares across the
// Atlantic and a bare Pacific says something about an antenna that a scatter of
// pins does not. Confirmed and merely worked are two fills of the same hue —
// the distinction is a STATE of one thing, not two categories, so it must not
// be two unrelated colours.
//
// 4 characters, never 6: at world zoom a 6-character square is sub-pixel, and
// aggregating to the big square is also how the count means "contacts in this
// square" rather than "contacts at this exact spot".

type Square = { grid: string; count: number; confirmed: boolean; band?: string; mode?: string };

// The SAME basemaps the Main-tab world map offers, imported rather than copied:
// two lists of tile servers is how one map ends up on a provider the other has
// already been blocked by. This map opened on a hardcoded dark Carto, which is
// the least legible of the four under translucent squares.

// Leaflet geometry is painted onto a CANVAS, and canvas takes a colour, not a
// stylesheet: "var(--success)" handed to fillStyle is simply invalid and the
// shape is silently not drawn — a map with a correct square count and nothing
// on it. So the token is RESOLVED to its literal value here, once per theme,
// which is also why every other map in this app passes hex.
// The Earth, in the projection's own terms. Latitude stops at ±85.0511 because
// that is where Web Mercator does.
const WORLD_BOUNDS = L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180));

function cssColour(token: string, fallback: string): string {
  try {
    const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
    return v || fallback;
  } catch { return fallback; }
}

// Mode scope. The names come straight from the backend's own classes ("ALL",
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
// not a square worked on FT8.
const SCOPES = [
  { key: 'ALL', label: 'gsm.all' },
  { key: 'PHONE', label: 'gsm.phone' },
  { key: 'CW', label: 'gsm.cw' },
  { key: 'DIGI', label: 'gsm.digital' },
  { key: 'FTX', label: 'gsm.ftx' },
] as const;
type ScopeKey = typeof SCOPES[number]['key'];

const SCOPE_KEY = 'opslog.gridMapScope';
// Chosen fill colours. Empty means "follow the theme", which is the default and
// stays the default: the tokens already track the four themes, and freezing a
// hex at first run would leave a dark-theme map painted in the light palette.
const COL_CONFIRMED_KEY = 'opslog.gridMapColorConfirmed';
const COL_WORKED_KEY = 'opslog.gridMapColorWorked';

// A colour input only accepts #rrggbb. The theme tokens ARE plain hex today, so
// this normally just passes them through — but a token that ever becomes oklch()
// or a named colour would silently drive the swatch to black, and a fallback is
// cheaper than that debugging session.
function asHex(v: string, fallback: string): string {
  return /^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : fallback;
}

export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) {
  const { t } = useI18n();
  const hostRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<L.Map | null>(null);
  const layerRef = useRef<L.LayerGroup | null>(null);
  const [squares, setSquares] = useState<Square[] | null>(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');
  const [scope, setScope] = useState<ScopeKey>(
    () => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
      ? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));

  const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
  const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
  const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
  // Repaint the squares when the THEME changes, not the basemap: the fills come
  // from theme tokens resolved at draw time, so a theme switch leaves them on
  // the old palette until something forces a redraw.
  const [themeTick, setThemeTick] = useState(0);
  useEffect(() => {
    const obs = new MutationObserver(() => setThemeTick((n) => n + 1));
    obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
    return () => obs.disconnect();
  }, []);

  const load = async (sc: ScopeKey = scope) => {
    setBusy(true); setErr('');
    try {
      const r = (await GridSquares(sc)) as any;
      setSquares((Array.isArray(r) ? r : []) as Square[]);
    } catch (e: any) {
      setErr(String(e?.message ?? e));
      setSquares([]);
    } finally { setBusy(false); }
  };
  useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);

  // One-time map creation. preferCanvas: a busy digital log is a few thousand
  // rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
  // pan.
  useEffect(() => {
    if (!hostRef.current || mapRef.current) return;
    const m = L.map(hostRef.current, {
      preferCanvas: true,
      zoomControl: true,
      attributionControl: true,
      // ONE world, not a row of them. worldCopyJump exists to make a path look
      // continuous across the date line; here it only meant the same continent
      // appeared two or three times, each carrying the same squares.
      worldCopyJump: false,
      // Zoom 0 is the whole planet in 256 pixels — the level at which Greenland
      // and Antarctica are both on screen. The old floor of 1 made that
      // impossible on any window wider than it is tall: one step out was already
      // too far in.
      minZoom: 0,

    }).setView([20, 0], 2);
    // The whole world, once, whatever the window is shaped like — computed by
    // Leaflet from the container rather than guessed with a zoom number.
    m.fitWorld({ animate: false });
    mapRef.current = m;
    layerRef.current = L.layerGroup().addTo(m);
    // Leaflet measures its container ONCE, when the map is created, and never
    // looks again. Here that measurement happens while the panel is still
    // laying out — so the map kept the height it had at that instant and the
    // rest of the panel stayed blank underneath it, whatever the window size.
    // Watching the host is the only fix that also survives a window resize, a
    // split-pane drag and the tab being shown for the first time.
    // fitWorld above ran against a container that may still have had no size —
    // the tab is mounted hidden. Fit ONCE more the first time it really has one,
    // and never again: after that the view belongs to the operator.
    let fitted = false;
    const ro = new ResizeObserver(() => {
      m.invalidateSize({ animate: false });
      const el = hostRef.current;
      if (!fitted && el && el.clientWidth > 0 && el.clientHeight > 0) {
        fitted = true;
        m.fitWorld({ animate: false });
      }
    });
    ro.observe(hostRef.current);
    return () => { ro.disconnect(); m.remove(); mapRef.current = null; layerRef.current = null; };
  }, []);

  // The chosen basemap, shared with the Main-tab map so picking one there and
  // finding another here cannot happen.
  const baseRef = useRef<L.TileLayer | null>(null);
  const labelsRef = useRef<L.TileLayer | null>(null);
  useEffect(() => {
    const m = mapRef.current;
    if (!m) return;
    // noWrap stops the world REPEATING; bounds stops Leaflet asking for the
    // empty columns either side of it. What is left beside the planet is then
    // the panel's own background rather than a wall of grey apologies.
    addBasemap(m, basemap, baseRef, labelsRef, { noWrap: true, bounds: WORLD_BOUNDS });
    baseRef.current?.bringToBack();
  }, [basemap]);

  // Redraw the squares.
  useEffect(() => {
    const layer = layerRef.current;
    if (!layer) return;
    layer.clearLayers();
    // Resolved once for the whole redraw, not per square: getComputedStyle
    // forces a style flush, and doing that a thousand times is a visible stall.
    const confirmedColour = confColour || cssColour('--success', '#16a34a');
    const workedFill = workedColour || cssColour('--chart-1', '#2a78d6');
    const meColour = cssColour('--warning', '#f59e0b');
    for (const sq of squares ?? []) {
      const b = gridSquareBounds(sq.grid);
      if (!b) continue;
      // One hue, two states. Confirmed is the solid, saturated one; worked is
      // the same colour held back — so the eye reads "more" and "less" of the
      // same thing rather than two unrelated facts.
      const colour = sq.confirmed ? confirmedColour : workedFill;
      L.rectangle([[b.south, b.west], [b.north, b.east]], {
        color: colour,
        weight: 0.5,
        opacity: sq.confirmed ? 0.9 : 0.5,
        fillColor: colour,
        fillOpacity: sq.confirmed ? 0.55 : 0.22,
      })
        .bindTooltip(
          `${sq.grid} — ${sq.count} QSO${sq.count > 1 ? 's' : ''}` +
          `${sq.band ? ` · ${sq.band}` : ''}${sq.mode ? ` ${sq.mode}` : ''}` +
          `${sq.confirmed ? ` · ${t('gsm.confirmed')}` : ''}`,
          { sticky: true },
        )
        .addTo(layer);
    }
    // The operator's own square, so the pattern has an origin to be read from.
    const me = myGrid ? gridToLatLon(myGrid) : null;
    if (me) {
      L.circleMarker([me.lat, me.lon], {
        radius: 4, color: meColour, weight: 2,
        fillColor: meColour, fillOpacity: 1,
      }).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer);
    }
  }, [squares, myGrid, t, themeTick, confColour, workedColour]);

  const stats = useMemo(() => {
    const list = squares ?? [];
    return { total: list.length, confirmed: list.filter((s) => s.confirmed).length };
  }, [squares]);

  return (
    // isolate is load-bearing, not tidiness. Leaflet puts its panes at z-index
    // 400 and its zoom control at 1000, in the PAGE's stacking context — and the
    // modals here are z-50. Without a stacking context of its own the map floats
    // over Preferences and hides the Save and Close buttons. isolation:isolate
    // confines every z-index Leaflet sets to this element.
    <section className={cn('isolate relative flex flex-col min-h-0 rounded-lg border border-border bg-card overflow-hidden', className)}>
      <header className="flex items-center gap-2 px-2.5 py-1.5 border-b border-border bg-muted/40 shrink-0 flex-wrap">
        <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
          {t('gsm.title')}
        </span>
        <div className="inline-flex rounded-md border border-border overflow-hidden">
          {SCOPES.map((s, i) => (
            <button key={s.key} type="button"
              onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
              className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
                scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
              {t(s.label)}
            </button>
          ))}
        </div>
        <span className="text-[11px] text-muted-foreground tabular-nums">
          {t('gsm.count', { n: stats.total, c: stats.confirmed })}
        </span>
        <select
          value={basemap}
          onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
          title={t('gsm.basemap')}
          className="h-6 rounded border border-border bg-background px-1 text-[11px]"
        >
          {(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
            <option key={k} value={k}>{BASEMAPS[k].label}</option>
          ))}
        </select>
        {/* Beside the basemap, because they answer the same question: what this
            map looks like. Written straight through — there is no Save here. */}
        <label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colConfirmed')}>
          <input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
            value={asHex(confColour || cssColour('--success', '#16a34a'), '#16a34a')}
            onChange={(e) => { setConfColour(e.target.value); writeUiPref(COL_CONFIRMED_KEY, e.target.value); }} />
          {t('gsm.confirmed')}
        </label>
        <label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colWorked')}>
          <input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
            value={asHex(workedColour || cssColour('--chart-1', '#2a78d6'), '#2a78d6')}
            onChange={(e) => { setWorkedColour(e.target.value); writeUiPref(COL_WORKED_KEY, e.target.value); }} />
          {t('gsm.worked')}
        </label>
        {(confColour || workedColour) && (
          <button type="button" title={t('gsm.colReset')}
            onClick={() => {
              setConfColour(''); setWorkedColour('');
              writeUiPref(COL_CONFIRMED_KEY, ''); writeUiPref(COL_WORKED_KEY, '');
            }}
            className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
        )}
        <span className="flex-1" />
        <button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
          className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
          {busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
        </button>
      </header>
      {err && <p className="px-2.5 py-1 text-[11px] text-destructive shrink-0">{err}</p>}
      {/* The map host must have a real height or Leaflet renders nothing at all
          — flex-1 + min-h-0, never a percentage. */}
      {/* The ground beside the planet is the panel's own background: Leaflet
          paints its container, and left to its default that surround was a
          bright white slab against a dark theme. */}
      <div ref={hostRef} className="flex-1 min-h-0 bg-card" />
      <footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground">
        <span className="inline-flex items-center gap-1.5">
          <span className="inline-block size-2.5 rounded-[2px]"
            style={{ background: confColour || 'var(--success)', opacity: 0.75 }} />
          {t('gsm.confirmed')}
        </span>
        <span className="inline-flex items-center gap-1.5">
          <span className="inline-block size-2.5 rounded-[2px]"
            style={{ background: workedColour || 'var(--chart-1)', opacity: 0.35 }} />
          {t('gsm.worked')}
        </span>
      </footer>
    </section>
  );
}
