"use client";

/**
 * Animated donut chart — visx geometry + framer-motion timing.
 *
 * This is the approach Bklit's charts use (@visx primitives driven by motion,
 * themed through CSS variables) rebuilt against Mantine tokens, since the Bklit
 * registry itself needs Tailwind + shadcn/ui which this app doesn't have.
 *
 * Effects: arcs wipe in clockwise on mount, the hovered slice springs outward
 * along its own bisector, and the centre total rolls to its new value.
 */

import { Portal, Stack, Text } from "@mantine/core";
import { ParentSize } from "@visx/responsive";
import { Pie } from "@visx/shape";
import { animate } from "framer-motion";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { usePromoTheme } from "../appearance";
import { AnimatedNumber, EASE_OUT, motion, useReducedMotion } from "../motion";
import styles from "./charts.module.css";

export interface DonutDatum {
  name: string;
  value: number;
  /**
   * Optional identifier the caller can use to route a click.
   *
   * Names are not reliable keys — two campaigns can share a name, and a slice
   * label is a display string — so the destination is resolved from this.
   */
  id?: string;
  color: string;
  /** Second gradient stop. Defaults to a darkened `color` when omitted. */
  colorTo?: string;
}

interface AnimatedDonutProps {
  data: DonutDatum[];
  height?: number;
  /** Ring thickness as a fraction of the outer radius (0–1). */
  thickness?: number;
  /** Small caption under the centre figure. */
  centerLabel?: string;
  /**
   * Idle centre figure, when the headline is not the sum of the slices.
   *
   * A breakdown whose ring is "X and not-X" has a caption naming only X, so the
   * summed total reads as the wrong number under it — "Logged In: 2,431" when 2,431
   * is everyone. Only the displayed figure changes: slice percentages stay measured
   * against the real sum, since that is what makes them add up to 100%.
   */
  centerValue?: number;
  /** Noun used in the tooltip, e.g. "promo code" → "3 promo codes". */
  unit?: string;
  /** Pixels the hovered slice travels outward. */
  explode?: number;
  /**
   * Makes slices clickable, for drilling into whatever the slice represents.
   *
   * Optional: only some donuts have somewhere to go (a campaign slice does, a
   * contact-completeness bucket does not), and a slice that looks clickable but
   * isn't is worse than one that doesn't invite the click.
   */
  onSliceClick?: (datum: DonutDatum) => void;
}

const TAU = Math.PI * 2;

/**
 * Space left above and below the ring, in px.
 *
 * Sized against the tooltip's own vertical offset (it renders 46px above the
 * cursor), so a tooltip raised from the topmost slice still clears the section
 * above.
 */
const TOOLTIP_CLEARANCE = 24;

export function AnimatedDonut({
  data,
  height = 220,
  thickness = 0.42,
  centerLabel,
  centerValue,
  unit,
  explode = 9,
  onSliceClick,
}: AnimatedDonutProps) {
  return (
    /*
     * Vertical clearance around every donut.
     *
     * The tooltip is portalled and follows the cursor, so hovering a slice near
     * the ring's top or bottom edge put it over whatever section sat flush above
     * or below. Done here rather than at each call site so all six donuts get it
     * and new ones can't forget.
     *
     * The padding is on this outer element, not the height-reserving one below:
     * that inner height is passed straight to the chart, so padding it would
     * shrink the drawn ring instead of adding room around it.
     */
    <div style={{ paddingBlock: TOOLTIP_CLEARANCE }}>
      {/* Height is reserved here: ParentSize measures 0 on the server and the
          first client paint, so without this the card would jump on hydration. */}
      <div style={{ height }}>
        <ParentSize debounceTime={30}>
          {({ width }) =>
            width < 10 ? null : (
              <DonutInner
                data={data}
                width={width}
                height={height}
                thickness={thickness}
                centerLabel={centerLabel}
                centerValue={centerValue}
                unit={unit}
                explode={explode}
                onSliceClick={onSliceClick}
              />
            )
          }
        </ParentSize>
      </div>
    </div>
  );
}

function DonutInner({
  data,
  width,
  height,
  thickness,
  centerLabel,
  centerValue,
  unit,
  explode,
  onSliceClick,
}: AnimatedDonutProps & {
  height: number;
  thickness: number;
  explode: number;
  width: number;
}) {
  const reduce = useReducedMotion();
  const gradientId = useId().replace(/:/g, "");
  const [progress, setProgress] = useState(reduce ? 1 : 0);
  const [hovered, setHovered] = useState<number | null>(null);
  const [cursor, setCursor] = useState({ x: 0, y: 0 });
  const wrapRef = useRef<HTMLDivElement | null>(null);

  const total = useMemo(
    () => data.reduce((sum, d) => sum + d.value, 0),
    [data],
  );

  // Signature of the slice set — restarts the wipe when the filters change the
  // data, but not when only the hover state moves.
  const dataKey = useMemo(
    () => data.map((d) => `${d.name}:${d.value}`).join("|"),
    [data],
  );

  useEffect(() => {
    if (reduce) {
      setProgress(1);
      return;
    }
    setProgress(0);
    const controls = animate(0, 1, {
      duration: 0.95,
      ease: EASE_OUT,
      onUpdate: setProgress,
    });
    return () => controls.stop();
  }, [dataKey, reduce]);

  // Drop the hover as soon as the pointer leaves, even if the SVG never fires
  // its own mouseleave (the page's other charts guard the same way).
  useEffect(() => {
    const handler = (e: MouseEvent) => {
      const el = wrapRef.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      const inside =
        e.clientX >= r.left &&
        e.clientX <= r.right &&
        e.clientY >= r.top &&
        e.clientY <= r.bottom;
      if (!inside) setHovered(null);
    };
    document.addEventListener("mousemove", handler);
    return () => document.removeEventListener("mousemove", handler);
  }, []);

  const { chartDepth } = usePromoTheme();
  const glossy = chartDepth === "glossy";

  const size = Math.min(width, height);

  /*
   * Room the ring must leave for everything drawn OUTSIDE it.
   *
   * The radius used to be `size / 2 - 2`, which assumed the ring was the only
   * thing on the canvas. Three things reach past it:
   *
   *   • the hovered slice travels `explode` px outward;
   *   • the slice drop-shadow is a Gaussian blur of stdDeviation 3, so it
   *     spreads roughly 3σ ≈ 9px further;
   *   • the ground-shadow ellipse is centred at 0.9R with ry 0.1R, so its
   *     lower edge lands at exactly 1.0R — the very bottom of the box — and
   *     then blurs past it.
   *
   * With only 2px of margin all three spilled over the edge: clipped where the
   * SVG hides overflow, and overlapping whatever sat underneath where it
   * didn't. Reserving the space here fixes every donut at once, because they
   * all render through this component.
   */
  const SHADOW_SIGMA = 3;
  const EDGE_CLEARANCE = 2 + explode + SHADOW_SIGMA * 3;
  const outerRadius = Math.max(8, size / 2 - EDGE_CLEARANCE);
  const innerRadius = outerRadius * (1 - thickness);
  const centerX = width / 2;
  const centerY = height / 2;
  const sweepEnd = TAU * progress;

  const active = hovered !== null ? data[hovered] : null;
  const activePct =
    active && total > 0 ? ((active.value / total) * 100).toFixed(1) : null;

  return (
    <div
      ref={wrapRef}
      className={styles.chartWrap}
      style={{ height }}
      onMouseMove={(e) => setCursor({ x: e.clientX, y: e.clientY })}
      onMouseLeave={() => setHovered(null)}
    >
      <svg width={width} height={height} role="img">
        <defs>
          {data.map((d, i) => (
            // Two real colour stops when the palette supplies them; otherwise
            // fall back to fading the single colour, which works for any format
            // (hex, oklch, a CSS var) without needing colour maths here.
            <linearGradient
              key={`${gradientId}-${i}`}
              id={`${gradientId}-${i}`}
              x1="0"
              y1="0"
              x2="0.9"
              y2="1"
            >
              <stop offset="0%" stopColor={d.color} stopOpacity={1} />
              <stop
                offset="100%"
                stopColor={d.colorTo ?? d.color}
                stopOpacity={d.colorTo ? 1 : 0.68}
              />
            </linearGradient>
          ))}

          {glossy && (
            <>
              {/* Specular sweep laid over the ring: bright at the top-left,
                  gone by the middle, faint bounce light at the base. */}
              <linearGradient
                id={`${gradientId}-gloss`}
                x1="0.1"
                y1="0"
                x2="0.55"
                y2="1"
              >
                <stop offset="0%" stopColor="#fff" stopOpacity={0.5} />
                <stop offset="38%" stopColor="#fff" stopOpacity={0.12} />
                <stop offset="62%" stopColor="#fff" stopOpacity={0} />
                <stop offset="100%" stopColor="#fff" stopOpacity={0.07} />
              </linearGradient>

              {/* Contact shadow under the ring. */}
              <radialGradient id={`${gradientId}-shadow`}>
                <stop offset="0%" stopColor="#0f172a" stopOpacity={0.34} />
                <stop offset="70%" stopColor="#0f172a" stopOpacity={0.12} />
                <stop offset="100%" stopColor="#0f172a" stopOpacity={0} />
              </radialGradient>

              <filter
                id={`${gradientId}-soften`}
                x="-30%"
                y="-30%"
                width="160%"
                height="160%"
              >
                <feGaussianBlur stdDeviation={3} />
              </filter>
            </>
          )}
        </defs>

        {/* Contact shadow, drawn first so everything sits on top of it. */}
        {glossy && (
          <ellipse
            cx={centerX}
            cy={centerY + outerRadius * 0.9}
            rx={outerRadius * 0.72}
            ry={outerRadius * 0.1}
            fill={`url(#${gradientId}-shadow)`}
            filter={`url(#${gradientId}-soften)`}
            pointerEvents="none"
          />
        )}

        <g transform={`translate(${centerX}, ${centerY})`}>
          <Pie
            data={data}
            pieValue={(d) => d.value}
            outerRadius={outerRadius}
            innerRadius={innerRadius}
            cornerRadius={3}
            padAngle={data.length > 1 ? 0.015 : 0}
          >
            {(pie) => {
              const arcs = pie.arcs
                .map((arc, i) => {
                  // Clip the arc to the running sweep so slices draw clockwise.
                  if (arc.startAngle > sweepEnd) return null;
                  const endAngle = Math.min(arc.endAngle, sweepEnd);
                  const d = pie.path({ ...arc, endAngle });
                  if (!d) return null;
                  const mid = (arc.startAngle + arc.endAngle) / 2 - Math.PI / 2;
                  return { arc, i, d, mid };
                })
                .filter((a): a is NonNullable<typeof a> => a !== null);

              return (
                <>
                  {arcs.map(({ arc, i, d, mid }) => {
                    const isHovered = hovered === i;
                    const dx = Math.cos(mid) * explode;
                    const dy = Math.sin(mid) * explode;

                    return (
                      <motion.g
                        key={`arc-${arc.data.name}-${i}`}
                        animate={{
                          x: isHovered && !reduce ? dx : 0,
                          y: isHovered && !reduce ? dy : 0,
                        }}
                        transition={{
                          type: "spring",
                          stiffness: 340,
                          damping: 24,
                        }}
                        onMouseEnter={() => setHovered(i)}
                        onClick={
                          onSliceClick
                            ? () => onSliceClick(arc.data)
                            : undefined
                        }
                        /* Only a drillable slice advertises a click; without a
                           handler the pointer cursor would promise navigation
                           that never happens. */
                        style={{
                          cursor: onSliceClick ? "pointer" : "default",
                        }}
                      >
                        <path
                          d={d}
                          fill={`url(#${gradientId}-${i})`}
                          stroke="var(--mantine-color-body)"
                          strokeWidth={1.5}
                          opacity={hovered === null || isHovered ? 1 : 0.42}
                          style={{ transition: "opacity 180ms ease" }}
                        />
                        {/* Gloss rides on top of the fill, clipped to the same
                            arc, and never intercepts the pointer. */}
                        {glossy && (
                          <path
                            d={d}
                            fill={`url(#${gradientId}-gloss)`}
                            opacity={hovered === null || isHovered ? 1 : 0.4}
                            pointerEvents="none"
                          />
                        )}
                      </motion.g>
                    );
                  })}
                </>
              );
            }}
          </Pie>
        </g>
      </svg>

      {/* Centre readout — the hovered slice, or the headline figure when idle.
          That headline is the slice total unless the caller overrode it, for a
          ring whose caption names one slice rather than the whole. */}
      <div className={styles.donutCenter} style={{ pointerEvents: "none" }}>
        <Stack gap={0} align="center">
          <div className={styles.donutValue}>
            <AnimatedNumber
              value={active ? active.value : (centerValue ?? total)}
            />
          </div>
          <Text
            size="xs"
            c="dimmed"
            lineClamp={1}
            maw={size * 0.55}
            ta="center"
          >
            {active ? active.name : (centerLabel ?? "Total")}
          </Text>
        </Stack>
      </div>

      {active && (
        <Portal>
          <div
            className={styles.tooltip}
            style={{ left: cursor.x + 14, top: cursor.y - 46 }}
          >
            <div className={styles.tooltipTitle}>
              <span
                className={styles.tooltipSwatch}
                style={{ background: active.color }}
              />
              {active.name}
            </div>
            <div>
              {active.value.toLocaleString()}
              {unit ? ` ${unit}${active.value === 1 ? "" : "s"}` : ""}
              {activePct ? ` · ${activePct}%` : ""}
            </div>
          </div>
        </Portal>
      )}
    </div>
  );
}
