"use client";

/**
 * World choropleth of signups per country.
 *
 * Complements the stacked bars rather than replacing them: a choropleth can only
 * encode one value per country, so the map answers "where are signups coming
 * from" and the bars below keep the per-campaign split. Hover is shared, so
 * pointing at a country highlights its bar row and vice versa.
 *
 * Two deliberate choices worth knowing:
 *
 *  - Uses the **50m** Natural Earth atlas, not 110m. 110m has only 177 countries
 *    and omits every small island nation — Maldives, Mauritius, Seychelles,
 *    Singapore, Bahrain, Malta, Barbados, Cabo Verde. For a resort business
 *    those are real markets, so dropping them silently was not acceptable.
 *  - That file is 739kb, so it's loaded with a dynamic `import()` and lands in
 *    its own chunk instead of the main bundle.
 */

import {
  ActionIcon,
  Group,
  Portal,
  Skeleton,
  Stack,
  Text,
  Tooltip,
} from "@mantine/core";
import {
  IconGlobe,
  IconMap2,
  IconMinus,
  IconPlus,
  IconRefresh,
} from "@tabler/icons-react";
import { EqualEarth, Graticule, Orthographic } from "@visx/geo";
import { ParentSize } from "@visx/responsive";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSessionStorageState } from "@/hooks/useSessionStorageState";
import type { Feature, FeatureCollection, Geometry } from "geojson";
import { usePromoTheme } from "../appearance";
import { buildAccentScale } from "../palette";
import { EASE_OUT, motion, useReducedMotion } from "../motion";
import styles from "./charts.module.css";
import { matchCountries, normaliseCountry } from "./countryNames";

/** One country as produced from the atlas by `topojson-client`. */
type CountryFeature = Feature<Geometry, { name: string }>;

export interface ChoroplethRow {
  country: string;
  value: number;
  /** Per-campaign detail, used for the tooltip. */
  breakdown?: Array<{ name: string; value: number; color: string }>;
  /**
   * Categorical fill, used when countries are coloured by which group they
   * belong to rather than by signup volume. When set it replaces the sequential
   * ramp, and the legend switches to naming the groups.
   */
  areaColor?: string;
  areaLabel?: string;
}

interface CountryChoroplethProps {
  rows: ChoroplethRow[];
  height?: number;
  /** Normalised map name of the highlighted country, or null. */
  hoveredKey: string | null;
  onHoverCountry: (mapKey: string | null) => void;
  /**
   * Makes countries with data clickable.
   *
   * Receives the country's own name (not the normalised map key), because callers
   * filter by the name their data uses.
   */
  onCountryClick?: (country: string) => void;
}

/** Cached across mounts so switching pages doesn't refetch/reparse the atlas. */
let atlasCache: CountryFeature[] | null = null;

function useWorldAtlas() {
  const [features, setFeatures] = useState<CountryFeature[] | null>(atlasCache);

  useEffect(() => {
    if (atlasCache) return;
    let cancelled = false;
    void (async () => {
      const [{ feature }, topology] = await Promise.all([
        import("topojson-client"),
        import("world-atlas/countries-50m.json"),
      ]);
      // The atlas ships as TopoJSON; `feature()` expands it to GeoJSON. Cast
      // through `never` because topojson-client's own types aren't installed.
      const topo = (topology.default ?? topology) as never;
      const collection = feature(
        topo,
        (topo as { objects: { countries: never } }).objects.countries,
      ) as unknown as FeatureCollection<Geometry, { name: string }>;
      if (cancelled) return;
      atlasCache = collection.features;
      setFeatures(collection.features);
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  return features;
}

export function CountryChoropleth(props: CountryChoroplethProps) {
  const height = props.height ?? 260;
  return (
    <div style={{ height }}>
      <ParentSize debounceTime={30}>
        {({ width }) =>
          // Skeleton, not null — see CountryCampaignTreemap: an empty panel is
          // indistinguishable from "no data", a placeholder is not.
          width < 10 ? (
            <Skeleton height={height} radius="md" />
          ) : (
            <MapInner {...props} width={width} height={height} />
          )
        }
      </ParentSize>
    </div>
  );
}

function MapInner({
  rows,
  width,
  height,
  hoveredKey,
  onHoverCountry,
  onCountryClick,
}: CountryChoroplethProps & { width: number; height: number }) {
  const features = useWorldAtlas();
  const reduce = useReducedMotion();
  const { accent, chartDepth } = usePromoTheme();
  const glossy = chartDepth !== "flat";
  const [cursor, setCursor] = useState({ x: 0, y: 0 });

  const [mode, setMode] = useSessionStorageState<"globe" | "flat">(
    "campaigns.map.mode",
    "globe",
  );
  const [zoomLevel, setZoomLevel] = useState(1);
  /** Globe orientation, [longitude, latitude] in degrees. */
  const [rotation, setRotation] = useState<[number, number]>([-15, -12]);
  /** Flat-map offset in px. */
  const [pan, setPan] = useState<[number, number]>([0, 0]);
  const drag = useRef<{ x: number; y: number } | null>(null);
  const [dragging, setDragging] = useState(false);

  const resetView = useCallback(() => {
    setZoomLevel(1);
    setRotation([-15, -12]);
    setPan([0, 0]);
  }, []);

  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
    drag.current = { x: e.clientX, y: e.clientY };
    setDragging(true);
    e.currentTarget.setPointerCapture(e.pointerId);
  };

  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
    setCursor({ x: e.clientX, y: e.clientY });
    if (!drag.current) return;
    const dx = e.clientX - drag.current.x;
    const dy = e.clientY - drag.current.y;
    drag.current = { x: e.clientX, y: e.clientY };

    if (mode === "globe") {
      // Sensitivity falls with zoom so a drag moves the same arc on screen
      // whether you're zoomed out or in.
      const k = 0.32 / zoomLevel;
      setRotation(([lambda, phi]) => [
        lambda + dx * k,
        // Clamped: past ±90° the globe flips inside out.
        Math.max(-90, Math.min(90, phi - dy * k)),
      ]);
    } else {
      setPan(([px, py]) => [px + dx, py + dy]);
    }
  };

  const endDrag = (e: React.PointerEvent<SVGSVGElement>) => {
    drag.current = null;
    setDragging(false);
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId);
    }
  };

  // Join the rows to geometry by normalised name, keeping whatever failed.
  const { byKey, unmatched, maxValue } = useMemo(() => {
    if (!features) {
      return {
        byKey: new Map<string, ChoroplethRow>(),
        unmatched: [],
        maxValue: 1,
      };
    }
    const mapNames = new Set(
      features.map((f) => normaliseCountry(f.properties.name)),
    );
    const result = matchCountries(
      rows.map((r) => ({ country: r.country, datum: r })),
      mapNames,
    );
    return {
      byKey: result.matched,
      unmatched: result.unmatched,
      maxValue: Math.max(1, ...rows.map((r) => r.value)),
    };
  }, [features, rows]);

  /*
   * Distinct groups present in the data, for the categorical legend.
   *
   * Declared above the early return below: it used to sit after it, so the
   * first render (atlas still loading) ran one fewer hook than later renders
   * and React threw "Rendered more hooks than during the previous render".
   */
  const areaLegend = useMemo(() => {
    const seen = new Map<string, string>();
    for (const row of rows) {
      if (row.areaColor && row.areaLabel && !seen.has(row.areaLabel)) {
        seen.set(row.areaLabel, row.areaColor);
      }
    }
    return Array.from(seen, ([label, color]) => ({ label, color }));
  }, [rows]);

  if (!features) return <Skeleton height={height} radius="md" />;

  /*
   * Sequential ramp taken from the accent's own scale.
   *
   * This previously built `oklch(L C hue)` with a hard-coded chroma ramp
   * (0.05 → 0.20) at the accent's hue, keeping the hue but discarding the
   * accent's saturation. The map therefore didn't match the colour applied
   * everywhere else: #876328 has chroma 0.089, so it rendered ~2.3x more
   * saturated than the bronze on the buttons and badges beside it.
   *
   * `buildAccentScale` is the exact ramp Mantine's primary colour is built from
   * and it preserves the accent's real chroma and lightness, so a country fill
   * and a themed button are shades of one colour.
   */
  const accentScale = buildAccentScale(accent);
  const fillFor = (value: number, row?: ChoroplethRow) => {
    // Categorical mode: the colour encodes group membership, not magnitude.
    if (row?.areaColor) return row.areaColor;
    if (value <= 0) return "var(--map-empty)";
    // sqrt so a handful of dominant countries don't flatten everyone else.
    const t = Math.sqrt(value / maxValue);
    // Indices 1..8: the ends are reserved so the palest fill still reads as
    // filled against empty land, and the darkest keeps a visible outline.
    const step = Math.min(8, Math.max(1, Math.round(1 + t * 7)));
    return accentScale[step];
  };

  const active = hoveredKey ? byKey.get(hoveredKey) : null;

  /*
   * Globe (Orthographic) is a true circle — that's the "round" look. Flat
   * (EqualEarth) is an ellipse but shows every country at once.
   *
   * The trade-off is real and worth stating: an orthographic globe hides the far
   * hemisphere, so roughly half the countries aren't visible until you rotate.
   * Hence the toggle rather than replacing flat outright.
   */
  const isGlobe = mode === "globe";

  // EqualEarth's natural scale is 177.158 at 960px wide (d3-geo default). The
  // globe is sized by radius instead, so it fills the shorter axis.
  const baseScale = isGlobe
    ? Math.min(width, height) / 2 - 6
    : (width / 960) * 177.158;
  const scale = baseScale * zoomLevel;

  /**
   * Ocean, graticule and land — shared by both projections. Typed loosely
   * because visx's Orthographic and EqualEarth pass structurally identical
   * render args under different generated types.
   */
  const renderLand = (projection: {
    path: (obj: never) => string | null;
    features: Array<{ feature: CountryFeature; path: string | null }>;
  }) => (
    <>
      {/* Ocean disc + lat/long grid behind the land. On the globe this is a
          true circle, which is what gives it the round, sphere-like read. */}
      <path
        d={projection.path({ type: "Sphere" } as never) ?? ""}
        className={styles.mapSphere}
      />
      <Graticule
        graticule={(g) => projection.path(g as never) ?? ""}
        className={styles.mapGraticule}
      />
      {projection.features.map(({ feature, path }, i) => {
        const key = normaliseCountry(feature.properties.name);
        const row = byKey.get(key);
        const value = row?.value ?? 0;
        const isActive = hoveredKey === key;
        const dimmed = hoveredKey !== null && !isActive;

        return (
          <motion.path
            key={`country-${feature.properties.name}-${i}`}
            d={path ?? ""}
            fill={fillFor(value, row)}
            stroke={
              isActive
                ? "var(--promo-accent, var(--mantine-color-indigo-6))"
                : "var(--map-stroke)"
            }
            // Divided by zoom so outlines stay hairlines when zoomed in rather
            // than turning into thick bands.
            strokeWidth={(isActive ? 1.4 : 0.4) / zoomLevel}
            // Countries with data fade in; empty land is drawn at once so the
            // outline is stable while the data lands.
            initial={reduce || value <= 0 ? false : { opacity: 0 }}
            animate={{ opacity: dimmed ? 0.55 : 1 }}
            transition={{ duration: 0.4, ease: EASE_OUT }}
            filter={
              glossy && isActive && value > 0 ? "url(#map-lift)" : undefined
            }
            /* Only a country with data is actionable — clicking an empty one
               would filter to nothing. */
            style={{
              cursor:
                value > 0 && onCountryClick
                  ? "pointer"
                  : value > 0
                    ? "pointer"
                    : "default",
            }}
            onMouseEnter={() => onHoverCountry(value > 0 ? key : null)}
            onClick={
              value > 0 && onCountryClick && row?.country
                ? () => onCountryClick(row.country)
                : undefined
            }
          />
        );
      })}
    </>
  );

  return (
    <div
      className={styles.chartWrap}
      style={{ height }}
      onMouseLeave={() => onHoverCountry(null)}
    >
      <div className={styles.zoomControls}>
        <Tooltip
          label={isGlobe ? "Switch to flat map" : "Switch to globe"}
          position="left"
          withArrow
        >
          <ActionIcon
            size="sm"
            variant="default"
            aria-label={isGlobe ? "Switch to flat map" : "Switch to globe"}
            onClick={() => {
              setMode(isGlobe ? "flat" : "globe");
              resetView();
            }}
          >
            {isGlobe ? <IconMap2 size={14} /> : <IconGlobe size={14} />}
          </ActionIcon>
        </Tooltip>
        <Tooltip label="Zoom in" position="left" withArrow>
          <ActionIcon
            size="sm"
            variant="default"
            aria-label="Zoom in"
            onClick={() => setZoomLevel((z) => Math.min(10, z * 1.4))}
          >
            <IconPlus size={14} />
          </ActionIcon>
        </Tooltip>
        <Tooltip label="Zoom out" position="left" withArrow>
          <ActionIcon
            size="sm"
            variant="default"
            aria-label="Zoom out"
            onClick={() => setZoomLevel((z) => Math.max(1, z * 0.72))}
          >
            <IconMinus size={14} />
          </ActionIcon>
        </Tooltip>
        <Tooltip
          label={isGlobe ? "Reset rotation" : "Reset view"}
          position="left"
          withArrow
        >
          <ActionIcon
            size="sm"
            variant="default"
            aria-label="Reset view"
            onClick={resetView}
          >
            <IconRefresh size={14} />
          </ActionIcon>
        </Tooltip>
      </div>

      <svg
        width={width}
        height={height}
        role="img"
        aria-label={
          isGlobe ? "Signups by country, rotatable globe" : "Signups by country"
        }
        className={`${styles.mapSurface} ${
          dragging ? styles.mapSurfaceDragging : ""
        }`}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        // Ctrl/⌘ + wheel only. Binding plain wheel would hijack page scrolling
        // every time the pointer crossed the map.
        onWheel={(event) => {
          if (!event.ctrlKey && !event.metaKey) return;
          event.preventDefault();
          setZoomLevel((z) =>
            Math.max(1, Math.min(10, z * (event.deltaY < 0 ? 1.12 : 0.89))),
          );
        }}
      >
        <defs>
          {glossy && (
            <filter id="map-lift" x="-20%" y="-20%" width="140%" height="140%">
              <feDropShadow
                dx="0"
                dy="1"
                stdDeviation="1.5"
                floodColor="#0f172a"
                floodOpacity="0.35"
              />
            </filter>
          )}
        </defs>

        {/* One render function, two projections: the globe rotates via `rotate`
            and the flat map offsets via `translate`, so neither needs a wrapping
            transform group. */}
        {isGlobe ? (
          <Orthographic<CountryFeature>
            data={features}
            scale={scale}
            translate={[width / 2, height / 2]}
            rotate={[rotation[0], rotation[1]]}
          >
            {renderLand}
          </Orthographic>
        ) : (
          <EqualEarth<CountryFeature>
            data={features}
            scale={scale}
            translate={[width / 2 + pan[0], height / 2 + pan[1]]}
          >
            {renderLand}
          </EqualEarth>
        )}
      </svg>

      {/* Sequential ramp, or the group names when colouring categorically — a
          gradient scale would be meaningless for categories. */}
      {areaLegend.length > 0 ? (
        /* Left-aligned so the legend reads with the map's left edge instead of
           drifting away from the content on wide screens. */
        <Group
          gap={10}
          align="center"
          justify="flex-start"
          mt={-6}
          pl={4}
          wrap="wrap"
        >
          {areaLegend.map((entry) => (
            <Group key={entry.label} gap={5} wrap="nowrap">
              <span
                className={styles.tooltipSwatch}
                style={{ background: entry.color }}
              />
              <Text size="xs" c="dimmed">
                {entry.label}
              </Text>
            </Group>
          ))}
        </Group>
      ) : (
        <Group gap={8} align="center" justify="flex-start" mt={-6} pl={4}>
          <Text size="xs" c="dimmed">
            0
          </Text>
          <div
            className={styles.mapLegend}
            style={{
              background: `linear-gradient(90deg, ${fillFor(0.0001)}, ${fillFor(maxValue)})`,
            }}
          />
          <Text size="xs" c="dimmed">
            {maxValue.toLocaleString()}
          </Text>
        </Group>
      )}

      {/*
        Countries the atlas has no shape for are named explicitly. Silently
        dropping them would make the map look complete when it isn't.
      */}
      {unmatched.length > 0 && (
        <Text size="xs" c="dimmed" mt={2}>
          Not on map: {unmatched.slice(0, 4).join(", ")}
          {unmatched.length > 4 ? ` +${unmatched.length - 4} more` : ""}
        </Text>
      )}

      {/*
       * Portalled to <body> on purpose.
       *
       * `.tooltip` is position: fixed, which resolves against the viewport only
       * while no ancestor establishes a containing block. `PageSection` animates
       * `y` and `filter: blur()`, and framer-motion leaves `filter: blur(0px)`
       * on the element once the animation ends — that is not `none`, so the
       * containing block is permanent. Without the portal the tooltip anchored
       * to that div and appeared far above the map on a scrolled page.
       */}
      {active && (
        <Portal>
          <div
            className={styles.tooltip}
            style={{ left: cursor.x + 14, top: cursor.y - 20 }}
          >
            <div className={styles.tooltipTitle}>
              {active.country}
              {active.areaLabel ? ` · ${active.areaLabel}` : ""}
            </div>
            <div>
              {active.value.toLocaleString()} signup
              {active.value === 1 ? "" : "s"}
            </div>
            {active.breakdown && active.breakdown.length > 0 && (
              <Stack gap={1} mt={4}>
                {active.breakdown.slice(0, 5).map((b) => (
                  <Group key={b.name} gap={6} wrap="nowrap">
                    <span
                      className={styles.tooltipSwatch}
                      style={{ background: b.color }}
                    />
                    <span style={{ fontWeight: 700 }}>{b.value}</span>
                    <span>{b.name}</span>
                  </Group>
                ))}
              </Stack>
            )}
          </div>
        </Portal>
      )}
    </div>
  );
}
