"use client";

import {
  Alert,
  Badge,
  Box,
  Button,
  Card,
  Container,
  Group,
  Loader,
  Modal,
  NumberInput,
  Select,
  SimpleGrid,
  Skeleton,
  Stack,
  Switch,
  Text,
  TextInput,
  Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { DatePickerInput } from "@mantine/dates";
import {
  IconCalendarCheck,
  IconCalendarStats,
  IconChartPie,
  IconChevronRight,
  IconFolders,
  IconGift,
  IconMapPin,
  IconPlus,
  IconSearch,
  IconTicket,
  IconToggleLeft,
  IconUserCheck,
  IconUsersGroup,
} from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import ConfirmModal from "@/layouts/shared/ConfirmModal";
import {
  createCampaign,
  deleteCampaign,
} from "@/lib/features/promo-code-campaigns/action";
import {
  getCampaignSignups,
  listCampaigns,
  listCountriesFromDB,
  type CampaignSignupsResult,
} from "@/lib/features/promo-code-campaigns/query";
import type {
  CampaignListResult,
  DashboardOverviewRow,
} from "@/lib/features/promo-code-campaigns/types";
import { CountryBreakdownBarChart } from "./_components/CountryBreakdownBarChart";
import type { AccessScope } from "@/lib/features/types";
import { ExportButton } from "@/lib/export/ExportButton";
import {
  getViewpointBookingAnalytics,
  type ViewpointOriginBreakdown,
} from "@/lib/features/viewpoint/query";
import {
  ExportLimitError,
  MAX_EXPORT_ROWS,
  withTimeout,
} from "@/lib/export/exportLimits";
import { ListTableFooter } from "@/lib/components/ListTableCard";
import { useSessionStorageState } from "@/hooks/useSessionStorageState";
import {
  CountryMultiSelect,
  DEFAULT_SIGNUP_WINDOW,
  isWindowActive,
  resolveSignupWindow,
  mapWithConcurrency,
  SignupWindowControl,
  type SignupWindowState,
} from "@/routes/reports/_shared";
import {
  CampaignsTable,
  type CampaignSortKey,
} from "./_components/CampaignsTable";
import {
  ActiveFilterChips,
  FilterDrawer,
  FilterSection,
  FilterTrigger,
  type FilterChip,
} from "./_components/FilterDrawer";
import {
  AppearanceMenu,
  PromoThemeProvider,
  usePromoAppearance,
} from "./_components/appearance";
import { StatTiles, type StatTileSpec } from "./_components/StatTiles";
import { BreakdownPanel, type Breakdown } from "./_components/BreakdownPanel";
import { DrillChevron, drillProps } from "./_components/drillable";
import {
  areaOf,
  AREAS,
  MAPPED_COUNTRY_COUNT,
  type Area,
} from "./_components/regions";
import { getPromoCodeByCode } from "@/lib/features/promo-codes/query";
import type { PromoCode } from "@/lib/features/promo-codes/types";
import {
  allocationFor,
  rewardValue,
  sumAllocations,
} from "@/lib/features/promo-codes/rewards";
import {
  AnimatedDonut,
  type DonutDatum,
} from "./_components/charts/AnimatedDonut";
import { AnimatedNumber, PageArrival, PageSection } from "./_components/motion";
import { DashboardSkeleton } from "./_components/PageSkeleton";
import styles from "./_components/promo.module.css";

/*
 * Booking slice labels.
 *
 * Named constants because the legend's drill-down maps a clicked slice back to a
 * booking kind. Matching on a literal string in two places would silently stop
 * working the moment one label was reworded.
 */
/*
 * Bookings taken through Karma Subito.
 *
 * Named for the system that took them rather than "internal", which described the
 * property type and left the reader guessing which product it belonged to. Curated
 * bookings are also Karma Subito's, but they stay their own slice — a curated event is
 * a different kind of booking, not a different source.
 */
const INTERNAL_SLICE = "Karma Subito Bookings";
/*
 * Imported bookings not yet reconciled against core.
 *
 * This slice was "Curated Bookings" when the card counted core's own bookings. It now
 * reports the imported data, where the third bucket is rows the core match has not run
 * for — genuinely unknown rather than a kind of booking, so it keeps a muted role.
 */
const CURATED_SLICE = "Not Yet Matched";
/*
 * Bookings keyed into Viewpoint by hand rather than taken through Karma Subito.
 *
 * Named for how the booking was made, not for the system it was read from — "Viewpoint"
 * described where we found it, which is not what anyone is asking when they look at a
 * booking source.
 *
 * Its own slice because core has no record of them at all — they are neither internal
 * nor curated, and folding them into either would change what that figure has always
 * counted. Bookings present in both systems are excluded upstream, so this never
 * double-counts the other two.
 */
const VIEWPOINT_SLICE = "Manual Bookings";

interface Props {
  initialList: CampaignListResult;
  initialDashboard: DashboardOverviewRow[];
  initialSearch: string;
  initialError?: string;
  accessScope: AccessScope;
}

export default function CampaignsClientPage({
  initialList,
  initialDashboard,
  initialSearch,
  initialError,
  accessScope,
}: Props) {
  const navigate = useNavigate();
  const [, setSearchParams] = useSearchParams();
  const [list, setList] = useState(initialList);
  const [search, setSearch] = useSessionStorageState(
    "campaignList.search",
    initialSearch,
  );
  const [createOpen, setCreateOpen] = useState(false);
  const [newName, setNewName] = useState("");
  const [submitting, setSubmitting] = useState(false);
  const [loading, setLoading] = useState(false);
  const [statusFilter, setStatusFilter] = useSessionStorageState<string | null>(
    "campaignList.status",
    "all",
  );
  const [sortBy, setSortBy] = useSessionStorageState<string | null>(
    "campaignList.sort",
    "most-signups",
  );
  const [dateRange, setDateRange] = useSessionStorageState<
    [Date | null, Date | null]
  >("campaignList.dateRange", [null, null]);
  const [minCount, setMinCount] = useSessionStorageState<number | null>(
    "campaignList.minCount",
    null,
  );
  const [maxCount, setMaxCount] = useSessionStorageState<number | null>(
    "campaignList.maxCount",
    null,
  );
  const [updatedByFilter, setUpdatedByFilter] = useSessionStorageState<string>(
    "campaignList.updatedBy",
    "",
  );

  // ---- signup window + country (business filters, on top of the chart) ----
  const [signupWindow, setSignupWindow] =
    useSessionStorageState<SignupWindowState>(
      "campaignList.window",
      DEFAULT_SIGNUP_WINDOW,
    );
  const [countrySel, setCountrySel] = useSessionStorageState<string[]>(
    "campaignList.country",
    [],
  );
  const [onlyWithSignups, setOnlyWithSignups] = useSessionStorageState(
    "campaignList.onlyWithSignups",
    false,
  );
  const windowRange = useMemo(
    () => resolveSignupWindow(signupWindow),
    [signupWindow],
  );
  // Per-campaign signups (window/country-aware) + the country options.
  const [signupsById, setSignupsById] = useState<Map<string, number>>(
    new Map(),
  );
  const [internalBookingsById, setInternalBookingsById] = useState<
    Map<string, number>
  >(new Map());
  const [externalBookingsById, setExternalBookingsById] = useState<
    Map<string, number>
  >(new Map());
  const [countryOptions, setCountryOptions] = useState<string[]>([]);
  const [byCountryCampaign, setByCountryCampaign] = useState<
    CampaignSignupsResult["byCountryCampaign"]
  >([]);
  const [codeCounts, setCodeCounts] = useState<
    NonNullable<CampaignSignupsResult["byPromoCode"]>
  >([]);
  const [contact, setContact] = useState<
    CampaignSignupsResult["contactCompleteness"] | null
  >(null);
  const [signupsLoading, setSignupsLoading] = useState(true);
  // Distinct from `signupsLoading`, which flips true again on every filter
  // change — the page skeleton should only cover the very first fetch.
  const [hasLoadedOnce, setHasLoadedOnce] = useState(false);

  // Fetch all countries from the DB `countries` table once on mount.
  // This drives the CountryMultiSelect options independently of the signup data.
  useEffect(() => {
    let cancelled = false;
    void (async () => {
      const res = await listCountriesFromDB();
      if (cancelled || !res.success) return;
      setCountryOptions(res.data.map((c) => c.name));
    })();
    return () => {
      cancelled = true;
    };
  }, []);
  /*
   * Reward values per promo code.
   *
   * The list endpoint does not return `rewards` — nothing in this codebase reads
   * rewards from a list response, only from the per-code detail fetch — so the
   * list alone leaves every code with an unknown value and the allocation empty.
   *
   * So: fetch detail for the codes that actually matter (those with at least one
   * logged-in member, since allocation is value × logged-in and a code with none
   * contributes nothing either way). Bounded concurrency, and already-known codes
   * are skipped, so switching filters doesn't refetch the same codes.
   */
  const [rewardsByCode, setRewardsByCode] = useState<Map<string, PromoCode>>(
    new Map(),
  );

  useEffect(() => {
    const wanted = codeCounts
      .filter((r) => r.loggedIn > 0)
      .map((r) => r.promoCode.trim().toUpperCase())
      .filter((code) => code.length > 0 && !rewardsByCode.has(code));
    if (wanted.length === 0) return;

    let cancelled = false;
    void (async () => {
      const fetched = await mapWithConcurrency(wanted, 6, async (code) => {
        const res = await getPromoCodeByCode(code);
        return res.success ? ([code, res.data] as const) : null;
      });
      if (cancelled) return;
      setRewardsByCode((prev) => {
        const next = new Map(prev);
        for (const entry of fetched) {
          if (entry) next.set(entry[0], entry[1] as PromoCode);
        }
        return next;
      });
    })();
    return () => {
      cancelled = true;
    };
    // `rewardsByCode` is intentionally not a dependency — it is written by this
    // effect, and including it would re-run on every merge.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [codeCounts]);

  const datasetWide =
    isWindowActive(signupWindow) ||
    countrySel.length > 0 ||
    onlyWithSignups ||
    (statusFilter !== null && statusFilter !== "all") ||
    Boolean(dateRange[0] || dateRange[1]) ||
    minCount !== null ||
    maxCount !== null ||
    updatedByFilter.trim().length > 0;

  // When signup filters are active the list goes dataset-wide: every matching
  // campaign is fetched and paginated client-side (so "signups today" filters
  // across all campaigns, not just the current server page).
  const [allCampaigns, setAllCampaigns] = useState<CampaignListResult["items"]>(
    [],
  );
  const [allCampaignsLoading, setAllCampaignsLoading] = useState(false);
  const [clientPage, setClientPage] = useState(1);

  const [showFullChart, setShowFullChart] = useState(false);
  const [filtersOpen, setFiltersOpen] = useState(false);
  const appearance = usePromoAppearance();

  // Filters live in a right-hand drawer but still apply live as controls change
  // — there's no Apply step, so the controls bind straight to committed state.
  // Each applied filter also surfaces as a removable chip on the filter bar, so
  // the count and the chip list come from one definition.
  const fmtChipDate = (value: unknown) => {
    const d = new Date(value as string);
    return Number.isFinite(d.getTime())
      ? d.toLocaleDateString(undefined, {
          day: "2-digit",
          month: "short",
          year: "numeric",
        })
      : "";
  };

  const filterChips = useMemo<FilterChip[]>(() => {
    const chips: FilterChip[] = [];

    if (isWindowActive(signupWindow)) {
      const presetLabels: Record<string, string> = {
        today: "Today",
        "7d": "Last 7 days",
        "30d": "Last 30 days",
        month: "This month",
      };
      const label =
        signupWindow.preset === "custom"
          ? [
              signupWindow.customFrom && fmtChipDate(signupWindow.customFrom),
              signupWindow.customTo && fmtChipDate(signupWindow.customTo),
            ]
              .filter(Boolean)
              .join(" → ")
          : (presetLabels[signupWindow.preset] ?? signupWindow.preset);
      chips.push({
        key: "window",
        label: `Signups in: ${label}`,
        onRemove: () => setSignupWindow(DEFAULT_SIGNUP_WINDOW),
      });
    }

    for (const country of countrySel) {
      chips.push({
        key: `country:${country}`,
        label: country,
        onRemove: () =>
          setCountrySel((prev) => prev.filter((c) => c !== country)),
      });
    }

    if (statusFilter && statusFilter !== "all") {
      chips.push({
        key: "status",
        label: statusFilter === "active" ? "Has promo codes" : "Empty",
        onRemove: () => setStatusFilter("all"),
      });
    }

    if (dateRange[0] || dateRange[1]) {
      const from = dateRange[0] ? fmtChipDate(dateRange[0]) : "any";
      const to = dateRange[1] ? fmtChipDate(dateRange[1]) : "any";
      chips.push({
        key: "created",
        label: `Created: ${from} → ${to}`,
        onRemove: () => setDateRange([null, null]),
      });
    }

    if (minCount !== null) {
      chips.push({
        key: "minCount",
        label: `Min codes: ${minCount}`,
        onRemove: () => setMinCount(null),
      });
    }

    if (maxCount !== null) {
      chips.push({
        key: "maxCount",
        label: `Max codes: ${maxCount}`,
        onRemove: () => setMaxCount(null),
      });
    }

    if (updatedByFilter.trim()) {
      chips.push({
        key: "updatedBy",
        label: `Updated by: ${updatedByFilter.trim()}`,
        onRemove: () => setUpdatedByFilter(""),
      });
    }

    if (onlyWithSignups) {
      chips.push({
        key: "onlyWithSignups",
        label: "Only with signups",
        onRemove: () => setOnlyWithSignups(false),
      });
    }

    return chips;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    signupWindow,
    countrySel,
    statusFilter,
    dateRange,
    minCount,
    maxCount,
    updatedByFilter,
    onlyWithSignups,
  ]);

  const handleResetFilters = () => {
    setStatusFilter("all");
    setDateRange([null, null]);
    setMinCount(null);
    setMaxCount(null);
    setUpdatedByFilter("");
    setSignupWindow(DEFAULT_SIGNUP_WINDOW);
    setCountrySel([]);
    setOnlyWithSignups(false);
  };
  const [deleteTarget, setDeleteTarget] = useState<{
    id: string;
    name: string;
  } | null>(null);
  const isFirstRender = useRef(true);

  const dashboardRows = useMemo(
    () =>
      [...initialDashboard].sort(
        (a, b) => Number(b.promo_code_count) - Number(a.promo_code_count),
      ),
    [initialDashboard],
  );

  const reload = async (nextPage: number, nextSearch: string) => {
    setLoading(true);
    try {
      const res = await listCampaigns({
        page: nextPage,
        pageSize: list.pagination.pageSize,
        search: nextSearch || undefined,
      });
      if (res.success) setList(res.data);
    } finally {
      setLoading(false);
    }
  };

  // Debounced auto-search on typing
  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      if (search === initialSearch) return;
    }
    const timer = setTimeout(() => {
      setSearchParams((prev) => {
        const next = new URLSearchParams(prev);
        if (search) next.set("search", search);
        else next.delete("search");
        next.set("page", "1");
        return next;
      });
      void reload(1, search);
    }, 350);
    return () => clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [search]);

  // Per-campaign signups for the window (+ optional country) and the country
  // options. One call covers every campaign, so signup filtering here is
  // dataset-wide even though the table itself is server-paginated.
  useEffect(() => {
    let cancelled = false;
    setSignupsLoading(true);
    void (async () => {
      try {
        const res = await getCampaignSignups({
          from: windowRange.from,
          to: windowRange.to,
          country: countrySel.length ? countrySel : undefined,
        });
        if (cancelled || !res.success) return;
        const sm = new Map<string, number>();
        const im = new Map<string, number>();
        const em = new Map<string, number>();
        for (const it of res.data.items) {
          sm.set(it.campaignId, it.signups);
          im.set(it.campaignId, it.internalBookings ?? 0);
          em.set(it.campaignId, it.externalBookings ?? 0);
        }
        setSignupsById(sm);
        setInternalBookingsById(im);
        setExternalBookingsById(em);
        // Per-code logged-in counts drive reward allocation. Kept separate from
        // the per-campaign maps because reward values are per code.
        setCodeCounts(res.data.byPromoCode ?? []);
        setContact(res.data.contactCompleteness ?? null);
        // countryOptions are now fetched from the DB at mount — do not
        // overwrite them here with the (smaller) signup-window country list.
        setByCountryCampaign(res.data.byCountryCampaign ?? []);
      } finally {
        if (!cancelled) {
          setSignupsLoading(false);
          setHasLoadedOnce(true);
        }
      }
    })();
    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [windowRange.from, windowRange.to, countrySel]);

  // Memoised: these are dependencies of the table's column definitions, so a
  // fresh function each render would rebuild every column on every render.
  const signupsOf = useCallback(
    (id: string) => signupsById.get(id) ?? 0,
    [signupsById],
  );
  const internalBookingsOf = useCallback(
    (id: string) => internalBookingsById.get(id) ?? 0,
    [internalBookingsById],
  );
  const externalBookingsOf = useCallback(
    (id: string) => externalBookingsById.get(id) ?? 0,
    [externalBookingsById],
  );

  // Fetch every matching campaign (all pages) when a signup filter is active,
  // so the filter/sort/pagination span the whole dataset.
  useEffect(() => {
    if (!datasetWide) {
      setAllCampaigns([]);
      return;
    }
    let cancelled = false;
    setAllCampaignsLoading(true);
    void (async () => {
      try {
        const acc: CampaignListResult["items"] = [];
        let page = 1;
        let totalPages = 1;
        do {
          const res = await listCampaigns({
            page,
            pageSize: 200,
            search: search || undefined,
          });
          if (!res.success) break;
          totalPages = res.data.pagination.totalPages || 1;
          acc.push(...res.data.items);
          page += 1;
        } while (page <= totalPages);
        if (!cancelled) setAllCampaigns(acc);
      } finally {
        if (!cancelled) setAllCampaignsLoading(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [datasetWide, search]);

  // Apply the active client-side filters + sort. Shared by the table view and
  // the chart so they reflect the same filters.
  const applyCampaignFilters = <T extends (typeof list.items)[number]>(
    source: T[],
  ): T[] => {
    let items = [...source];
    if (statusFilter === "active") {
      items = items.filter((r) => Number(r.promo_code_count || 0) > 0);
    } else if (statusFilter === "empty") {
      items = items.filter((r) => Number(r.promo_code_count || 0) === 0);
    }
    // "Has signups in window" — implied when a window or country is active.
    if (
      onlyWithSignups ||
      countrySel.length > 0 ||
      isWindowActive(signupWindow)
    ) {
      items = items.filter((r) => signupsOf((r as { id: string }).id) > 0);
    }
    if (dateRange[0]) {
      const from = new Date(dateRange[0] as unknown as string).getTime();
      if (Number.isFinite(from)) {
        items = items.filter(
          (r) => r.created_at && new Date(r.created_at).getTime() >= from,
        );
      }
    }
    if (dateRange[1]) {
      const toDate = new Date(dateRange[1] as unknown as string);
      if (Number.isFinite(toDate.getTime())) {
        toDate.setHours(23, 59, 59, 999);
        const toTs = toDate.getTime();
        items = items.filter(
          (r) => r.created_at && new Date(r.created_at).getTime() <= toTs,
        );
      }
    }
    if (minCount !== null) {
      items = items.filter((r) => Number(r.promo_code_count || 0) >= minCount);
    }
    if (maxCount !== null) {
      items = items.filter((r) => Number(r.promo_code_count || 0) <= maxCount);
    }
    if (updatedByFilter.trim()) {
      const q = updatedByFilter.trim().toLowerCase();
      items = items.filter((r) =>
        (r.updated_by || r.created_by || "").toLowerCase().includes(q),
      );
    }
    switch (sortBy) {
      case "newest":
        items.sort(
          (a, b) =>
            new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
        );
        break;
      case "oldest":
        items.sort(
          (a, b) =>
            new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
        );
        break;
      case "most-codes":
        items.sort(
          (a, b) =>
            Number(b.promo_code_count || 0) - Number(a.promo_code_count || 0),
        );
        break;
      case "most-signups":
        items.sort(
          (a, b) =>
            signupsOf((b as { id: string }).id) -
            signupsOf((a as { id: string }).id),
        );
        break;
      case "most-internal":
        items.sort(
          (a, b) =>
            internalBookingsOf((b as { id: string }).id) -
            internalBookingsOf((a as { id: string }).id),
        );
        break;
      case "most-curated":
        items.sort(
          (a, b) =>
            externalBookingsOf((b as { id: string }).id) -
            externalBookingsOf((a as { id: string }).id),
        );
        break;
      case "recently-updated":
        items.sort(
          (a, b) =>
            new Date(b.updated_at || b.created_at).getTime() -
            new Date(a.updated_at || a.created_at).getTime(),
        );
        break;
      case "name":
        items.sort((a, b) => a.name.localeCompare(b.name));
        break;
    }
    return items;
  };

  // Full filtered+sorted set. Dataset-wide when a signup filter is active,
  // otherwise the current server page.
  const filteredItems = useMemo(
    () => applyCampaignFilters(datasetWide ? allCampaigns : list.items),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      datasetWide,
      allCampaigns,
      list.items,
      statusFilter,
      sortBy,
      dateRange,
      minCount,
      maxCount,
      updatedByFilter,
      onlyWithSignups,
      countrySel,
      signupsById,
      internalBookingsById,
      externalBookingsById,
      signupWindow,
    ],
  );

  // Client-side pagination for the dataset-wide view.
  const clientPageSize = list.pagination.pageSize;
  const clientTotalPages = Math.max(
    1,
    Math.ceil(filteredItems.length / Math.max(clientPageSize, 1)),
  );
  const clientSafePage = Math.min(clientPage, clientTotalPages);
  useEffect(() => {
    setClientPage(1);
  }, [datasetWide, filteredItems.length, clientPageSize]);

  const visibleItems = useMemo(
    () =>
      datasetWide
        ? filteredItems.slice(
            (clientSafePage - 1) * clientPageSize,
            clientSafePage * clientPageSize,
          )
        : filteredItems,
    [datasetWide, filteredItems, clientSafePage, clientPageSize],
  );

  // Build chart data from the same filtered list that drives the table, so
  // the global Filters button affects both at once.
  const visibleCampaignIds = useMemo(
    () => new Set(filteredItems.map((c) => c.id)),
    [filteredItems],
  );

  const allChartData = useMemo(() => {
    const eligible = dashboardRows.filter(
      (row) =>
        visibleCampaignIds.has(row.id) && Number(row.promo_code_count || 0) > 0,
    );
    // Accent-anchored palette rather than the shared `colorByIndex`, so the
    // slices belong to the same colour family as the rest of the page.
    const palette = appearance.theme.series(eligible.length);
    return eligible.map((row, i) => ({
      // Carried so a slice click can open the campaign; names aren't unique.
      id: row.id,
      name: row.name,
      value: Number(row.promo_code_count || 0),
      color: palette[i].from,
      colorTo: palette[i].to,
    }));
  }, [dashboardRows, visibleCampaignIds, appearance.theme]);

  const countryChartEntities = useMemo(
    () =>
      dashboardRows
        .filter((row) => visibleCampaignIds.has(row.id))
        .map((row) => ({ id: row.id, name: row.name })),
    [dashboardRows, visibleCampaignIds],
  );
  const countryChartRows = useMemo(
    () =>
      byCountryCampaign
        .filter((row) => visibleCampaignIds.has(row.campaignId))
        .map((row) => ({
          country: row.country,
          id: row.campaignId,
          signups: row.signups,
        })),
    [byCountryCampaign, visibleCampaignIds],
  );

  const totalCampaignSignups = useMemo(() => {
    if (datasetWide) {
      return allCampaigns.reduce(
        (total, campaign) => total + signupsOf(campaign.id),
        0,
      );
    }
    let sum = 0;
    for (const val of signupsById.values()) {
      sum += val;
    }
    return sum;
  }, [datasetWide, allCampaigns, signupsById]);

  // Summed over the whole filtered set, not just the visible page — matches the
  // set behind `visibleCampaignIds`, so every figure on the Analytics card and
  // in the KPI tiles describes the same campaigns.
  /*
   * The booking split exactly as the bookings page reports it.
   *
   * Read from the same endpoint that page uses, rather than recomputed here from core
   * bookings plus an attributed manual figure. Those were two different populations —
   * core promo bookings against every imported row — so the card and the page it links
   * to disagreed, and a badge reading 60 opened a page reading 167. One source means
   * they cannot diverge.
   */
  const [bookingOrigin, setBookingOrigin] =
    useState<ViewpointOriginBreakdown | null>(null);
  const [importedBookings, setImportedBookings] = useState(0);
  useEffect(() => {
    let cancelled = false;
    // Unfiltered, matching the bookings page's own unfiltered header figures.
    void getViewpointBookingAnalytics({})
      .then((res) => {
        if (cancelled || !res.success || !res.data) return;
        setBookingOrigin(res.data.origin ?? null);
        setImportedBookings(res.data.total ?? 0);
      })
      .catch(() => {});
    return () => {
      cancelled = true;
    };
  }, []);

  const bookingTotals = useMemo(() => {
    let internal = 0;
    let external = 0;
    for (const r of filteredItems) {
      internal += internalBookingsOf(r.id);
      external += externalBookingsOf(r.id);
    }
    return { internal, external, total: internal + external };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [filteredItems, internalBookingsById, externalBookingsById]);

  const bookingChartData = useMemo(() => {
    const { internal, external } = appearance.theme.roles;
    /*
     * Viewpoint-direct is a third slice, not folded into internal or curated.
     *
     * These are bookings core has no record of, so they are neither — and adding them
     * to an existing slice would change what that slice has always meant. They also
     * cannot double-count: the figure counts only bookings with no core counterpart.
     */
    const [viewpointPaint] = appearance.theme.series(1);
    // Zero-value slices are dropped so the donut doesn't render empty arcs.
    const data: DonutDatum[] = [
      {
        name: INTERNAL_SLICE,
        value: bookingOrigin?.subito ?? 0,
        color: internal.from,
        colorTo: internal.to,
      },
      {
        name: CURATED_SLICE,
        value: bookingOrigin?.neverChecked ?? 0,
        color: external.from,
        colorTo: external.to,
      },
      {
        name: VIEWPOINT_SLICE,
        value: bookingOrigin?.viewpointOnly ?? 0,
        color: viewpointPaint.from,
        colorTo: viewpointPaint.to,
      },
    ].filter((d) => d.value > 0);
    return { data, hasData: data.length > 0 };
  }, [bookingOrigin, appearance.theme]);

  const MAX_LEGEND_ROWS = 5;
  const chartData = allChartData;
  const legendRows = useMemo(
    () => allChartData.slice(0, MAX_LEGEND_ROWS),
    [allChartData],
  );
  const hiddenCount = Math.max(0, allChartData.length - legendRows.length);
  const totalPromoCodes = allChartData.reduce((s, r) => s + r.value, 0);

  const totalCampaigns = datasetWide
    ? allCampaigns.length
    : list.pagination.total;

  const windowLabel = useMemo(() => {
    const labels: Record<string, string> = {
      all: "all time",
      today: "today",
      "7d": "last 7 days",
      "30d": "last 30 days",
      month: "this month",
      custom: "custom range",
    };
    return labels[signupWindow.preset] ?? "all time";
  }, [signupWindow.preset]);

  /*
   * Reward allocation across every promo code in view.
   *
   * total = Σ over codes of (that code's reward value × that code's logged-in
   * members). Logged-in is the basis, not registrations — a member only receives
   * the reward on first login, which is the rule the per-member column on the
   * promo-code detail page already applies.
   *
   * `missingRewardData` is surfaced rather than swallowed: the upstream promo-code
   * list may omit `rewards`, and a code with unknown reward value must not be
   * indistinguishable from one worth zero.
   */
  const rewardAllocation = useMemo(() => {
    const byCode = rewardsByCode;

    const points: ReturnType<typeof allocationFor>[] = [];
    let missingRewardData = 0;
    // Member basis is counted independently of reward availability — see below.
    let registered = 0;
    let loggedIn = 0;
    let pointsPending = 0;

    for (const row of codeCounts) {
      // Only codes belonging to a currently-visible campaign count, so the
      // figures respond to the filters like everything else. Each code counts
      // once even if shared by several campaigns.
      if (!row.campaignIds.some((id) => visibleCampaignIds.has(id))) continue;

      /*
       * The basis counts every code in view, whether or not its reward value is
       * known. Previously it only accumulated for codes that had reward data,
       * which under-reported "signed up": reward values are only fetched for
       * codes with at least one logged-in member, so a code with signups but no
       * logins was silently dropped from the member counts too.
       */
      registered += row.signups;
      loggedIn += row.loggedIn;

      const promo = byCode.get(row.promoCode.trim().toUpperCase()) ?? null;
      if (
        !promo ||
        (rewardValue(promo, "points") === null &&
          rewardValue(promo, "discount") === null)
      ) {
        // Only flag codes that would have contributed: with no logged-in member
        // the allocation is zero regardless of the reward value.
        if (row.loggedIn > 0) missingRewardData += 1;
        continue;
      }
      const counts = { registered: row.signups, loggedIn: row.loggedIn };
      points.push(allocationFor(promo, "points", counts));

      /*
       * What the still-pending members would be worth once they log in. Same
       * per-code value, applied to `registered − loggedIn` instead. Not a
       * liability that has been incurred — it's the outstanding upside.
       */
      const pendingMembers = Math.max(0, row.signups - row.loggedIn);
      pointsPending += (rewardValue(promo, "points") ?? 0) * pendingMembers;
    }

    const pointsTotal = sumAllocations(points);
    return {
      points: pointsTotal,
      missingRewardData,
      /*
       * Member basis behind the totals, over every code in view.
       *
       * This counts each code once. The Signups KPI tile sums *per-campaign*
       * totals, and the API adds a shared code's signups to every campaign it is
       * mapped to — so that tile double-counts codes attached to more than one
       * campaign and will read slightly higher here. This figure is the
       * de-duplicated member count.
       */
      registered,
      loggedIn,
      pending: Math.max(0, registered - loggedIn),
      pointsPending,
    };
  }, [rewardsByCode, codeCounts, visibleCampaignIds]);

  /*
   * Reward allocation per campaign, for the donut.
   *
   * Rolled up from per-code figures rather than campaign-level ones: the reward
   * value belongs to the code, so `Σ(code value × code logged-in)` is the only
   * correct way to attribute a campaign's total. A code mapped to several
   * campaigns contributes to each — the same convention the signup roll-up uses.
   */
  const allocationByCampaign = useMemo(() => {
    const nameById = new Map(dashboardRows.map((r) => [r.id, r.name]));
    const byCode = rewardsByCode;

    const totals = new Map<string, { points: number }>();
    for (const row of codeCounts) {
      const promo = byCode.get(row.promoCode.trim().toUpperCase());
      if (!promo) continue;
      const counts = { registered: row.signups, loggedIn: row.loggedIn };
      const points = allocationFor(promo, "points", counts).total ?? 0;
      if (points === 0) continue;

      for (const id of row.campaignIds) {
        if (!visibleCampaignIds.has(id)) continue;
        const cur = totals.get(id) ?? { points: 0 };
        cur.points += points;
        totals.set(id, cur);
      }
    }

    const rows = Array.from(totals, ([campaignId, v]) => ({
      campaignId,
      name: nameById.get(campaignId) ?? campaignId,
      ...v,
    }))
      .filter((r) => r.points > 0)
      .sort((a, b) => b.points - a.points);

    const palette = appearance.theme.series(rows.length);
    return rows.map((r, i) => ({
      ...r,
      color: palette[i].from,
      colorTo: palette[i].to,
    }));
  }, [
    codeCounts,
    rewardsByCode,
    visibleCampaignIds,
    dashboardRows,
    appearance.theme,
  ]);

  // Campaigns with no points allocated drop out rather than rendering
  // zero-width slices.
  const allocationDonutData = useMemo<DonutDatum[]>(
    () =>
      allocationByCampaign
        .filter((r) => r.points > 0)
        .map((r) => ({
          // Carried so a slice click can open the campaign; names aren't unique.
          id: r.campaignId,
          name: r.name,
          value: r.points,
          color: r.color,
          colorTo: r.colorTo,
        })),
    [allocationByCampaign],
  );

  /*
   * Contact completeness of the signups.
   *
   * Buckets come from live member data — `users.email` and
   * `member_profiles.mobile` — not from the KCGM lead import, which is
   * Excel-derived and would not reconcile with the signup counts on this page.
   *
   * "Reachable" is Phone + Email only. A member with just one channel is counted
   * in its own bucket rather than folded into reachable, because a campaign that
   * needs both cannot use them.
   */
  const contactSlices = useMemo<DonutDatum[]>(() => {
    if (!contact || contact.total === 0) return [];
    const [both, phone, email] = appearance.theme.series(3);
    const slices: DonutDatum[] = [
      {
        id: "both",
        name: "Phone + Email",
        value: contact.phoneAndEmail,
        color: both.from,
        colorTo: both.to,
      },
      {
        id: "phone",
        name: "Phone only",
        value: contact.phoneOnly,
        color: phone.from,
        colorTo: phone.to,
      },
      {
        id: "email",
        name: "Email only",
        value: contact.emailOnly,
        color: email.from,
        colorTo: email.to,
      },
      {
        id: "none",
        name: "None",
        value: contact.neither,
        // Theme-derived, not a fixed grey — "not reachable" is still part of this
        // palette and must move when the accent does.
        color: appearance.theme.muted.from,
        colorTo: appearance.theme.muted.to,
      },
    ];
    // Every bucket is kept, zeros included — a zero is information here, and the
    // panel drops empty slices from the donut itself.
    return slices;
  }, [contact, appearance.theme]);

  /*
   * Member split behind the allocation: who has logged in and therefore been
   * granted their reward, against who has signed up but not logged in yet.
   *
   * Charted as members rather than reward value because that is the population
   * question — how many people have actually claimed. The reward consequence of
   * each group is shown alongside as text.
   */
  const memberSplitData = useMemo<DonutDatum[]>(() => {
    const [grantedColor] = appearance.theme.series(1);
    return [
      {
        // Bucket keys, matching the drill-down's LOGIN_BUCKETS.
        id: "logged_in",
        name: "Logged in",
        value: rewardAllocation.loggedIn,
        color: grantedColor.from,
        colorTo: grantedColor.to,
      },
      {
        id: "pending",
        name: "Pending first login",
        value: rewardAllocation.pending,
        color: appearance.theme.muted.from,
        colorTo: appearance.theme.muted.to,
      },
    ].filter((slice) => slice.value > 0);
  }, [rewardAllocation, appearance.theme]);

  /*
   * Sign-ups by business area.
   *
   * Built from `byCountryCampaign`, which already carries live per-country signup
   * counts for the visible campaigns — so no new query is needed.
   *
   * Members with no country recorded are derived rather than counted: the API
   * skips rows with an empty country (`if (!rowCountry) continue`), so they never
   * appear in the per-country data. The remainder against the signup total is
   * therefore exactly the no-country population, and both figures are
   * per-campaign, so the subtraction is like-for-like.
   */
  const areaSlices = useMemo<DonutDatum[]>(() => {
    const totals = new Map<Area, number>();
    let placed = 0;

    for (const row of byCountryCampaign) {
      if (!visibleCampaignIds.has(row.campaignId)) continue;
      const area = areaOf(row.country);
      totals.set(area, (totals.get(area) ?? 0) + row.signups);
      placed += row.signups;
    }

    const noCountry = Math.max(0, totalCampaignSignups - placed);
    if (noCountry > 0) {
      totals.set(
        "No Country Info",
        (totals.get("No Country Info") ?? 0) + noCountry,
      );
    }

    const palette = appearance.theme.series(AREAS.length);
    return AREAS.map((area, i) => ({
      // The unformatted area, so a slice click can name its bucket. The label is
      // display-only and `EU / UK / ROW` is not a key.
      id: area,
      name: area === "EU_UK_ROW" ? "EU / UK / ROW" : area,
      value: totals.get(area) ?? 0,
      /*
       * Unclassified buckets stay muted so they don't read as a real market — but
       * muted now means a pale tint of the accent, not a fixed grey, so a theme
       * change reaches this slice too.
       */
      color:
        area === "No Country Info"
          ? appearance.theme.muted.from
          : palette[i].from,
      colorTo:
        area === "No Country Info" ? appearance.theme.muted.to : palette[i].to,
    }));
  }, [
    byCountryCampaign,
    visibleCampaignIds,
    totalCampaignSignups,
    appearance.theme,
  ]);

  /*
   * Where a breakdown slice goes when clicked.
   *
   * Two destinations, because the breakdowns split into two kinds. Slices that
   * *are* a campaign (Promo codes, Points allocated) open that campaign, which
   * already shows its codes and its allocation. Slices that are a member segment
   * (Contact, Areas, Logged In) open the signup drill-down on the matching bucket,
   * carrying the dashboard's signup window so the child starts from the same
   * scope rather than resetting to the default.
   */
  const openCampaign = useCallback(
    (slice: DonutDatum) => {
      if (slice.id) navigate(`/admin/promo-code-campaigns/${slice.id}`);
    },
    [navigate],
  );

  const openSignupBreakdown = useCallback(
    (dimension: "contact" | "area" | "login") => (slice: DonutDatum) => {
      const params = new URLSearchParams();
      params.set("dimension", dimension);
      if (slice.id) params.set("bucket", slice.id);
      navigate(`/admin/contact-completeness?${params.toString()}`);
    },
    [navigate],
  );

  /*
   * Every part-to-whole breakdown, in one uniform shape for the panel.
   *
   * Order is deliberate: the code and member views come first, then the reward
   * views describe what those members earned. Bookings is deliberately absent —
   * it has its own Bookings Analytics section, and carrying it here too meant the
   * same donut appeared twice on one page. Breakdowns with no data are
   * dropped by the panel itself rather than filtered here, so a filter change
   * can't leave the selector pointing at an empty view.
   */
  const breakdowns = useMemo<Breakdown[]>(() => {
    return [
      {
        key: "codes",
        label: "Promo codes",
        unit: "promo code",
        centerLabel: "Total promo codes",
        slices: chartData,
        // Each slice is a campaign, so its own page is the drill-down — it lists
        // the very codes this slice counted.
        onSliceClick: openCampaign,
      },

      {
        key: "contact",
        label: "Contact",
        unit: "member",
        centerLabel: "Signups",
        slices: contactSlices,
        onSliceClick: openSignupBreakdown("contact"),
        note: contact
          ? `${contact.total.toLocaleString()} signups · reachable means phone and email on file`
          : undefined,
      },
      {
        key: "area",
        label: "Areas",
        unit: "member",
        centerLabel: "Signups",
        slices: areaSlices,
        onSliceClick: openSignupBreakdown("area"),
        note: `${MAPPED_COUNTRY_COUNT} countries mapped to India or SEAP explicitly · every other country counts as rest-of-world in EU / UK / ROW`,
      },
      {
        key: "members",
        label: "Logged In",
        unit: "member",
        centerLabel: "Logged In",
        slices: memberSplitData,
        onSliceClick: openSignupBreakdown("login"),
        total: rewardAllocation.loggedIn,
        note: `${rewardAllocation.loggedIn.toLocaleString()} logged in of ${rewardAllocation.registered.toLocaleString()} signed up · rewards are granted on a member's first login`,
      },
      {
        key: "reward",
        label: "Points allocated",
        unit: "point",
        centerLabel: "Points allocated",
        slices: allocationDonutData,
        // A campaign's allocation is explained on its own page, not by a member list.
        onSliceClick: openCampaign,
        note: `${rewardAllocation.loggedIn.toLocaleString()} logged in of ${rewardAllocation.registered.toLocaleString()} signed up`,
      },
    ];
  }, [
    contactSlices,
    contact,
    areaSlices,
    memberSplitData,
    allocationDonutData,
    rewardAllocation,
    chartData,
    openCampaign,
    openSignupBreakdown,
  ]);

  const bookingScopeCodes = useMemo(() => {
    const visibleCampaignIds = new Set(filteredItems.map((c) => c.id));
    const codes = new Set<string>();
    for (const row of codeCounts) {
      if (row.campaignIds.some((id) => visibleCampaignIds.has(id))) {
        codes.add(row.promoCode);
      }
    }
    return Array.from(codes);
  }, [codeCounts, filteredItems]);

  /**
   * Opens the bookings page.
   *
   * No parameters. It used to carry `kind`, `preset`, `scope` and sometimes `codes`,
   * which pre-filtered the destination — and once the bookings page moved to the
   * imported Viewpoint data those filters described a population it no longer showed,
   * so a badge reading 60 opened a page reading 167. One destination, one set of
   * numbers, and the figures on this card now come from that same data.
   */
  const openBookingsPage = () => {
    navigate("/admin/promo-code-bookings");
  };

  const bookingsBreakdown = useMemo<Breakdown[]>(
    () => [
      {
        key: "bookings",
        label: "Bookings",
        unit: "booking",
        centerLabel: "Total bookings",
        slices: bookingChartData.data,
        /*
         * The Viewpoint slice has no equivalent on the core bookings page — that page
         * lists core bookings, and these are precisely the ones it has never had. It is
         * inert rather than sending the reader somewhere the number cannot be found.
         */
        // Every slice goes to the same page now, because that page shows all three
        // buckets rather than one kind at a time.
        onSliceClick: () => openBookingsPage(),
      },
    ],

    [bookingChartData.data],
  );

  const statTiles = useMemo<StatTileSpec[]>(() => {
    const [c0, c1, c2, c4] = appearance.theme.series(4);
    return [
      {
        key: "campaigns",
        label: "Campaigns",
        value: totalCampaigns,
        color: c0.from,
        colorTo: c0.to,
        icon: <IconFolders size={18} stroke={1.8} />,
        hint:
          filterChips.length > 0
            ? `${filteredItems.length} match the filters`
            : "All campaigns",
      },
      {
        key: "codes",
        label: "Promo Codes",
        value: totalPromoCodes,
        color: c1.from,
        colorTo: c1.to,
        icon: <IconTicket size={18} stroke={1.8} />,
        hint: `Mapped across ${allChartData.length} campaign${
          allChartData.length === 1 ? "" : "s"
        }`,
      },
      {
        key: "signups",
        label: "Signups",
        value: totalCampaignSignups,
        color: c2.from,
        colorTo: c2.to,
        icon: <IconUsersGroup size={18} stroke={1.8} />,
        hint: `Members joined · ${windowLabel}`,
      },
      // {
      //   key: "rewards",
      //   label: "Points allocated",
      //   value: rewardAllocation.points.total,
      //   color: c3.from,
      //   colorTo: c3.to,
      //   icon: <IconGift size={18} stroke={1.8} />,
      //   hint:
      //     rewardAllocation.missingRewardData > 0
      //       ? `${rewardAllocation.loggedIn.toLocaleString()} logged in · ${rewardAllocation.missingRewardData} code${rewardAllocation.missingRewardData === 1 ? "" : "s"
      //       } missing reward data`
      //       : `${rewardAllocation.loggedIn.toLocaleString()} logged in of ${rewardAllocation.registered.toLocaleString()} signed up`,
      // },
      {
        key: "bookings",
        label: "Bookings",
        /*
         * Core bookings plus the Viewpoint-only ones, counted once each.
         *
         * The Viewpoint figure deliberately excludes bookings that also exist in core,
         * so this is a union rather than a sum of overlapping sets. Before any report is
         * imported the added term is 0 and the tile reads exactly as it always did.
         */
        /*
         * The imported total, straight from the bookings page's own figure. It used to
         * be core's promo bookings plus an attributed manual count — two different
         * populations added together, which is why this tile and that page disagreed.
         */
        value: importedBookings,
        color: c4.from,
        colorTo: c4.to,
        icon: <IconCalendarCheck size={18} stroke={1.8} />,
        percent: importedBookings
          ? ((bookingOrigin?.subito ?? 0) / importedBookings) * 100
          : 0,
        // The Viewpoint term is named only when there is one, so the hint doesn't
        // carry a permanent "· 0 Viewpoint" for consoles that never import.
        hint: `${bookingOrigin?.subito ?? 0} Karma Subito · ${
          bookingOrigin?.viewpointOnly ?? 0
        } manual`,
      },
    ];
  }, [
    appearance.theme,
    bookingOrigin,
    importedBookings,
    totalCampaigns,
    filteredItems.length,
    filterChips.length,
    totalPromoCodes,
    allChartData.length,
    totalCampaignSignups,
    windowLabel,
    rewardAllocation,
  ]);

  const onPageChange = (p: number) => {
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      next.set("page", String(p));
      return next;
    });
    void reload(p, search);
  };

  const onPageSizeChange = (value: string | null) => {
    const size = Number(value) || 25;
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      next.set("pageSize", String(size));
      next.set("page", "1");
      return next;
    });
    void (async () => {
      setLoading(true);
      try {
        const res = await listCampaigns({
          page: 1,
          pageSize: size,
          search: search || undefined,
        });
        if (res.success) setList(res.data);
      } finally {
        setLoading(false);
      }
    })();
  };

  const onCreate = async () => {
    const name = newName.trim();
    if (!name) return;
    setSubmitting(true);
    try {
      const res = await createCampaign({ name });
      if (res.success) {
        notifications.show({ color: "green", message: "Campaign created" });
        setCreateOpen(false);
        setNewName("");
        await reload(1, search);
      } else {
        notifications.show({
          color: "red",
          message: res.message || "Failed to create campaign",
        });
      }
    } catch (e: any) {
      notifications.show({
        color: "red",
        message: e?.message || "Failed to create campaign",
      });
    } finally {
      setSubmitting(false);
    }
  };

  const fmtDateTime = (value: unknown) => {
    if (!value) return "";
    const d = new Date(value as string);
    return Number.isFinite(d.getTime()) ? d.toLocaleString() : "";
  };

  const getExportData = async (signal: AbortSignal) => {
    const all: CampaignListResult["items"] = [];
    const pageSize = 200;
    let page = 1;
    let totalPages = 1;
    do {
      if (signal.aborted)
        throw new DOMException("Export cancelled", "AbortError");
      const res = await withTimeout(
        listCampaigns({ page, pageSize, search: search || undefined }),
      );
      if (!res.success)
        throw new Error(res.message || "Failed to load campaigns");
      if (res.data.pagination.total > MAX_EXPORT_ROWS)
        throw new ExportLimitError(res.data.pagination.total);
      totalPages = res.data.pagination.totalPages || 1;
      all.push(...res.data.items);
      page += 1;
    } while (page <= totalPages);
    const rows = all.map((row, i) => ({
      "#": i + 1,
      Name: row.name,
      "Promo Codes": Number(row.promo_code_count || 0),
      "Created At": fmtDateTime(row.created_at),
      "Updated At": fmtDateTime(row.updated_at),
      "Updated By": row.updated_by || row.created_by || "",
    }));
    return {
      sheetName: "Campaigns",
      columns: [
        { key: "#" as const, label: "#", width: 6 },
        { key: "Name" as const, label: "Name", width: 30 },
        { key: "Promo Codes" as const, label: "Promo Codes", width: 14 },
        { key: "Created At" as const, label: "Created At", width: 20 },
        { key: "Updated At" as const, label: "Updated At", width: 20 },
        { key: "Updated By" as const, label: "Updated By", width: 26 },
      ],
      rows,
    };
  };

  return (
    <PromoThemeProvider theme={appearance.theme}>
      <Container fluid px="xl" py="xl">
        <Group justify="space-between" mb="lg">
          <Group gap="sm">
            <Title order={3}>Campaigns</Title>
            <Badge variant="light" size="lg">
              {totalCampaigns} campaign
              {totalCampaigns === 1 ? "" : "s"}
            </Badge>
            {datasetWide && allCampaignsLoading && <Loader size="xs" />}
          </Group>
          <Group gap="xs">
            <FilterTrigger
              activeCount={filterChips.length}
              onClick={() => setFiltersOpen(true)}
            />
            {/*
             * Super admin only. The appearance settings write to the document
             * root, so they are not a private per-user preference — they change
             * the look of every promo page for whoever is signed in on this
             * browser. Keeping the control behind superAdmin stops a regular
             * operator from restyling the console.
             */}
            {accessScope.superAdmin && (
              <AppearanceMenu
                background={appearance.background}
                accent={appearance.accent}
                accentStyle={appearance.accentStyle}
                chartDepth={appearance.chartDepth}
                surface={appearance.surface}
                paletteMode={appearance.paletteMode}
                setBackground={appearance.setBackground}
                setAccent={appearance.setAccent}
                setAccentStyle={appearance.setAccentStyle}
                setChartDepth={appearance.setChartDepth}
                setSurface={appearance.setSurface}
                setPaletteMode={appearance.setPaletteMode}
              />
            )}
            <ExportButton
              label="Export Campaigns"
              filename="promo-code-campaigns"
              getData={getExportData}
              section="campaigns"
            />
            {accessScope.create && (
              <Button
                leftSection={<IconPlus size={16} />}
                onClick={() => setCreateOpen(true)}
              >
                Create Campaign
              </Button>
            )}
          </Group>
        </Group>

        {initialError && (
          <Alert color="red" mb="lg">
            {initialError}
          </Alert>
        )}

        {/* What's currently filtered stays on the page even though the controls
          themselves live in the drawer. */}
        <ActiveFilterChips chips={filterChips} onReset={handleResetFilters} />

        {/* First load shows a layout-matching skeleton so nothing reflows when
            the data lands. Content is swapped, not hidden: rendering it behind
            `display: none` would leave every ParentSize chart measuring 0 wide. */}
        {!hasLoadedOnce && <DashboardSkeleton />}

        {/* Filters — grouped by what they narrow, applied live as they change.
            Outside the skeleton swap so the drawer stays usable while loading. */}
        <FilterDrawer
          opened={filtersOpen}
          onClose={() => setFiltersOpen(false)}
          activeCount={filterChips.length}
          onReset={handleResetFilters}
        >
          <FilterSection
            icon={<IconCalendarStats size={15} />}
            title="Timeframe"
            description="Which signups the figures count"
          >
            <SignupWindowControl
              value={signupWindow}
              onChange={setSignupWindow}
            />
            <Switch
              label="Only campaigns with signups in this window"
              checked={onlyWithSignups}
              onChange={(e) => setOnlyWithSignups(e.currentTarget.checked)}
            />
          </FilterSection>

          <FilterSection
            icon={<IconMapPin size={15} />}
            title="Location"
            description="Signups from these countries only"
          >
            <CountryMultiSelect
              value={countrySel}
              onChange={setCountrySel}
              options={countryOptions}
            />
          </FilterSection>

          <FilterSection
            icon={<IconToggleLeft size={15} />}
            title="Campaign state"
            description="Promo code count and creation date"
          >
            <Select
              label="Status"
              value={statusFilter ?? "all"}
              onChange={(v) => setStatusFilter(v ?? "all")}
              allowDeselect={false}
              data={[
                { value: "all", label: "All" },
                { value: "active", label: "Has promo codes" },
                { value: "empty", label: "Empty" },
              ]}
            />
            <Group gap="xs" grow align="flex-end">
              <NumberInput
                label="Min codes"
                placeholder="Any"
                min={0}
                value={minCount ?? ""}
                onChange={(v) => setMinCount(typeof v === "number" ? v : null)}
              />
              <NumberInput
                label="Max codes"
                placeholder="Any"
                min={0}
                value={maxCount ?? ""}
                onChange={(v) => setMaxCount(typeof v === "number" ? v : null)}
              />
            </Group>
            <Group gap="xs" grow align="flex-end">
              <DatePickerInput
                label="Created from"
                placeholder="Any"
                value={dateRange[0]}
                onChange={(val) =>
                  setDateRange(([_, end]) => [val as Date | null, end])
                }
                clearable
                valueFormat="DD MMM YYYY"
              />
              <DatePickerInput
                label="Created to"
                placeholder="Any"
                value={dateRange[1]}
                onChange={(val) =>
                  setDateRange(([start]) => [start, val as Date | null])
                }
                clearable
                valueFormat="DD MMM YYYY"
              />
            </Group>
          </FilterSection>

          <FilterSection
            icon={<IconUserCheck size={15} />}
            title="Attribution"
            description="Who last touched the campaign"
            withDivider={false}
          >
            <TextInput
              label="Updated by"
              placeholder="email contains…"
              value={updatedByFilter}
              onChange={(e) => setUpdatedByFilter(e.currentTarget.value)}
            />
          </FilterSection>
        </FilterDrawer>

        {/* One arrival timeline for the page: sections rise in tree order, so
            adding a section can't collide with a hand-tuned per-card delay. */}
        {hasLoadedOnce && (
          <PageArrival>
            {/* KPI row — figures roll up whenever the filters change. */}
            <PageSection>
              <Box mb="md">
                <StatTiles tiles={statTiles} loading={signupsLoading} />
              </Box>
            </PageSection>

            {/* Analytics Card – Bookings Analytics & Promo Code Distribution side-by-side, Country × Campaign below */}
            <PageSection>
              <Card withBorder radius="lg" p="md" mt="md" mb="md">
                <Group justify="space-between" align="center" mb="md">
                  <Group gap={8} align="center">
                    <IconChartPie size={17} stroke={1.8} />
                    <Text fw={700} size="md">
                      Analytics
                    </Text>
                  </Group>
                  <Badge variant="light" size="md" radius="sm">
                    <AnimatedNumber value={totalCampaignSignups} /> TOTAL
                    SIGNUPS
                  </Badge>
                </Group>

                <SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" mb="lg">
                  {/* Left Column: Bookings Analytics (Internal vs Curated) */}
                  <Box
                    style={{
                      borderRight:
                        "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))",
                      paddingRight: "20px",
                    }}
                  >
                    <Group justify="space-between" align="center" mb="xs">
                      <Text size="md" fw={700}>
                        Bookings Analytics
                      </Text>
                      {/* Same role colours as the donut slices and the table's
                    booking columns. Kept alongside the panel's own legend
                    because these two are the split people scan for first. */}
                      {/* The donut gives the split but not which bookings they
                          are, so each badge opens the bookings page on that kind.
                          That page is reachable only from here — it has no nav
                          entry of its own. */}
                      <Group gap="xs">
                        <button
                          type="button"
                          className={styles.dataBadgeButton}
                          data-role="internal"
                          onClick={() => openBookingsPage()}
                          title="View Karma Subito bookings"
                        >
                          Karma Subito: {bookingOrigin?.subito ?? 0}
                        </button>
                        <button
                          type="button"
                          className={styles.dataBadgeButton}
                          data-role="curated"
                          onClick={() => openBookingsPage()}
                          title="Imported bookings not yet reconciled against core"
                        >
                          Not yet matched: {bookingOrigin?.neverChecked ?? 0}
                        </button>
                        {/* All three badges open the same page, which is where all
                            three figures come from. */}
                        <button
                          type="button"
                          className={styles.dataBadgeButton}
                          data-role="manual"
                          onClick={() => openBookingsPage()}
                          title="View manual bookings"
                        >
                          Manual: {bookingOrigin?.viewpointOnly ?? 0}
                        </button>
                      </Group>
                    </Group>

                    {/*
                      Says which badge belongs to which system, since the three are not
                      the same kind of thing: internal and curated are what a Karma
                      Subito booking was for, manual is where the booking came from.
                    */}
                    {/* Says where these figures come from, since they are the
                        imported Viewpoint bookings rather than core's own — the same
                        numbers the bookings page shows. */}
                    <Text size="xs" c="dimmed" mb="xs">
                      From the imported Viewpoint bookings. Karma Subito ones
                      core already has; manual ones were keyed into Viewpoint
                      directly.
                    </Text>

                    {bookingChartData.hasData ? (
                      <BreakdownPanel
                        breakdowns={bookingsBreakdown}
                        loading={signupsLoading}
                        height={220}
                      />
                    ) : (
                      <Stack
                        align="center"
                        justify="center"
                        py="xl"
                        style={{ opacity: 0.6 }}
                      >
                        <Text size="sm" c="dimmed">
                          No booking activity recorded in the selected range
                          yet.
                        </Text>
                      </Stack>
                    )}
                  </Box>

                  {/* Right Column: Promo Code Distribution */}
                  <Box style={{ paddingLeft: "8px" }}>
                    <Text size="md" fw={700} mb="xs">
                      Promo Code Distribution
                    </Text>
                    {loading && chartData.length === 0 ? (
                      <Skeleton height={220} radius="md" />
                    ) : chartData.length === 0 ? (
                      <Stack align="center" justify="center" mih={220}>
                        <IconChartPie size={48} stroke={1.2} opacity={0.3} />
                        <Text c="dimmed" size="sm">
                          No promo codes mapped to campaigns yet.
                        </Text>
                      </Stack>
                    ) : (
                      <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
                        <AnimatedDonut
                          data={chartData}
                          height={220}
                          centerLabel="Total promo codes"
                          unit="promo code"
                          /* A slice is a campaign, so clicking it opens that
                             campaign rather than merely filtering the page. The
                             legend beside it uses the same handler. */
                          onSliceClick={openCampaign}
                        />
                        <Stack gap="xs">
                          <Group justify="flex-start" mb={4}>
                            <Badge variant="light" size="md">
                              {totalPromoCodes} total promo code
                              {totalPromoCodes === 1 ? "" : "s"}
                            </Badge>
                          </Group>
                          {legendRows.map((slice) => {
                            const pct = (
                              (slice.value / totalPromoCodes) *
                              100
                            ).toFixed(1);
                            return (
                              <Group
                                key={slice.name}
                                className={styles.legendRow}
                                justify="flex-start"
                                gap="xs"
                                wrap="nowrap"
                                /* Same destination as the slice it labels — the
                                   list and the ring are one control, so reading a
                                   campaign's name should get you there too. */
                                {...drillProps(
                                  slice.id
                                    ? () => openCampaign(slice)
                                    : undefined,
                                  slice.name,
                                )}
                              >
                                <Group
                                  gap="xs"
                                  wrap="nowrap"
                                  style={{ minWidth: 0 }}
                                >
                                  <span
                                    className={styles.legendSwatch}
                                    style={{ background: slice.color }}
                                  />
                                  <Text size="sm" lineClamp={1}>
                                    {slice.name}
                                  </Text>
                                </Group>
                                <Group gap={6} wrap="nowrap">
                                  <Text size="sm" fw={600}>
                                    {slice.value}
                                  </Text>
                                  <Text size="xs" c="dimmed">
                                    ({pct}%)
                                  </Text>
                                  {slice.id && <DrillChevron />}
                                </Group>
                              </Group>
                            );
                          })}
                          {hiddenCount > 0 && (
                            <Group justify="flex-start" mt="xs">
                              <Button
                                variant="light"
                                size="xs"
                                radius="xl"
                                rightSection={<IconChevronRight size={14} />}
                                onClick={() => setShowFullChart(true)}
                              >
                                Show {hiddenCount} more
                              </Button>
                            </Group>
                          )}
                        </Stack>
                      </SimpleGrid>
                    )}
                  </Box>
                </SimpleGrid>

                {/*
                  One panel for every part-to-whole breakdown. These were five
                  separate donut-plus-legend blocks stacked vertically — same
                  shape each time, and the card ran very long. A selector picks
                  which one to show.
                */}
                <hr className={styles.gradientRule} />
                <Box mt="md" pt="md" mb="md">
                  <Group
                    justify="space-between"
                    align="center"
                    mb="sm"
                    wrap="wrap"
                  >
                    <Group gap={8} align="center">
                      <IconChartPie size={16} stroke={1.8} />
                      <Text size="md" fw={700}>
                        Breakdowns
                      </Text>
                    </Group>
                    <Group gap="xs">
                      {rewardAllocation.missingRewardData > 0 && (
                        <Badge variant="light" color="orange" size="xs">
                          {rewardAllocation.missingRewardData} code
                          {rewardAllocation.missingRewardData === 1
                            ? ""
                            : "s"}{" "}
                          w/o reward data
                        </Badge>
                      )}
                    </Group>
                  </Group>

                  <BreakdownPanel
                    breakdowns={breakdowns}
                    loading={signupsLoading}
                    height={220}
                  />
                </Box>

                {/* Full-width below: Country × Campaign */}
                <hr className={styles.gradientRule} />
                <Box mt="md" pt="md">
                  {signupsLoading ? (
                    <Skeleton height={240} radius="md" />
                  ) : (
                    <CountryBreakdownBarChart
                      /*
                       * Clicking a country filters the page to it rather than
                       * navigating away — the country *is* a filter here, and the
                       * charts and table all react to it. Toggles, so clicking the
                       * same country again clears it.
                       */
                      onCountryClick={(country) =>
                        setCountrySel((prev) =>
                          prev.includes(country)
                            ? prev.filter((c) => c !== country)
                            : [...prev, country],
                        )
                      }
                      title="Country × Campaign"
                      entities={countryChartEntities}
                      rows={countryChartRows}
                    />
                  )}
                </Box>
              </Card>
            </PageSection>

            <PageSection>
              <Card withBorder radius="lg" p="lg">
                <Group
                  justify="space-between"
                  align="center"
                  wrap="wrap"
                  gap="sm"
                  mb="sm"
                >
                  <Group gap={8} align="center">
                    <Text fw={700} size="lg">
                      Campaigns
                    </Text>
                    <Badge variant="light" color="gray" size="sm" radius="sm">
                      <AnimatedNumber
                        value={
                          datasetWide
                            ? filteredItems.length
                            : list.pagination.total
                        }
                      />
                    </Badge>
                    {loading && <Loader size="xs" />}
                  </Group>
                  <Group gap="sm" align="center" wrap="wrap">
                    <TextInput
                      placeholder="Search campaigns by name…"
                      leftSection={<IconSearch size={16} />}
                      value={search}
                      onChange={(e) => setSearch(e.currentTarget.value)}
                      style={{ flex: 1, minWidth: 240, maxWidth: 360 }}
                    />
                    <Select
                      size="sm"
                      variant="filled"
                      w={200}
                      value={sortBy ?? "newest"}
                      onChange={(v) => setSortBy(v ?? "newest")}
                      allowDeselect={false}
                      data={[
                        { value: "most-codes", label: "Most promo codes" },
                        { value: "most-signups", label: "Most signups" },
                        { value: "newest", label: "Newest first" },
                        { value: "oldest", label: "Oldest first" },
                        {
                          value: "recently-updated",
                          label: "Recently updated",
                        },

                        {
                          value: "most-internal",
                          label: "Most internal bookings",
                        },
                        {
                          value: "most-curated",
                          label: "Most curated bookings",
                        },
                        { value: "name", label: "Name (A–Z)" },
                      ]}
                    />
                  </Group>
                </Group>

                <CampaignsTable
                  rows={visibleItems}
                  rowOffset={
                    datasetWide
                      ? (clientSafePage - 1) * clientPageSize
                      : (list.pagination.page - 1) * list.pagination.pageSize
                  }
                  loading={loading || (datasetWide && allCampaignsLoading)}
                  accessScope={accessScope}
                  sortBy={(sortBy ?? "newest") as CampaignSortKey}
                  onSortChange={(key) => setSortBy(key)}
                  signupsOf={signupsOf}
                  internalBookingsOf={internalBookingsOf}
                  externalBookingsOf={externalBookingsOf}
                  onView={(id) => navigate(`/admin/promo-code-campaigns/${id}`)}
                  onDelete={setDeleteTarget}
                  animationKey={`${datasetWide ? clientSafePage : list.pagination.page}:${sortBy}:${visibleItems.length}`}
                />

                {list.items.length > 0 && (
                  <ListTableFooter
                    pageSize={
                      datasetWide ? clientPageSize : list.pagination.pageSize
                    }
                    pageSizeOptions={["10", "25", "50", "100"]}
                    onPageSizeChange={onPageSizeChange}
                    rangeStart={
                      datasetWide
                        ? filteredItems.length === 0
                          ? 0
                          : (clientSafePage - 1) * clientPageSize + 1
                        : list.pagination.total === 0
                          ? 0
                          : (list.pagination.page - 1) *
                              list.pagination.pageSize +
                            1
                    }
                    rangeEnd={
                      datasetWide
                        ? Math.min(
                            clientSafePage * clientPageSize,
                            filteredItems.length,
                          )
                        : Math.min(
                            list.pagination.page * list.pagination.pageSize,
                            list.pagination.total,
                          )
                    }
                    total={
                      datasetWide ? filteredItems.length : list.pagination.total
                    }
                    page={datasetWide ? clientSafePage : list.pagination.page}
                    totalPages={
                      datasetWide
                        ? clientTotalPages
                        : list.pagination.totalPages
                    }
                    onPageChange={datasetWide ? setClientPage : onPageChange}
                  />
                )}
              </Card>
            </PageSection>
          </PageArrival>
        )}

        <Modal
          opened={createOpen}
          onClose={() => setCreateOpen(false)}
          title="Create Campaign"
          centered
        >
          <Stack>
            <TextInput
              label="Campaign Name"
              placeholder="e.g. Summer 2026 Push"
              required
              value={newName}
              onChange={(e) => setNewName(e.currentTarget.value)}
              data-autofocus
            />
            <Group justify="flex-end">
              <Button variant="subtle" onClick={() => setCreateOpen(false)}>
                Cancel
              </Button>
              <Button onClick={onCreate} loading={submitting}>
                Create
              </Button>
            </Group>
          </Stack>
        </Modal>

        <ConfirmModal
          opened={deleteTarget !== null}
          onClose={() => setDeleteTarget(null)}
          title="Delete campaign?"
          message={
            deleteTarget
              ? `Are you sure you want to delete campaign "${deleteTarget.name}"?`
              : ""
          }
          confirmLabel="Delete"
          cancelLabel="Cancel"
          confirmColor="red"
          onConfirm={async () => {
            if (!deleteTarget) return;
            const res = await deleteCampaign(deleteTarget.id);
            if (res.success) {
              notifications.show({
                color: "green",
                message: "Campaign deleted",
              });
              void reload(list.pagination.page, search);
            } else {
              notifications.show({
                color: "red",
                message: res.message || "Failed to delete",
              });
            }
          }}
        />

        <Modal
          opened={showFullChart}
          onClose={() => setShowFullChart(false)}
          title="All campaigns – promo code distribution"
          size="lg"
          centered
        >
          <Stack gap="xs" mah={520} style={{ overflowY: "auto" }}>
            {allChartData.map((slice, i) => {
              const pct = (
                (slice.value / (totalPromoCodes || 1)) *
                100
              ).toFixed(1);
              return (
                <Group
                  key={slice.name}
                  className={styles.legendRow}
                  justify="space-between"
                  gap="xs"
                  wrap="nowrap"
                  py={6}
                  {...drillProps(
                    slice.id
                      ? () => {
                          // Closed first: the modal would otherwise stay open
                          // over the campaign page it just navigated to.
                          setShowFullChart(false);
                          openCampaign(slice);
                        }
                      : undefined,
                    slice.name,
                  )}
                  style={{
                    borderBottom:
                      i === allChartData.length - 1
                        ? undefined
                        : "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))",
                    ...(slice.id ? { cursor: "pointer" } : {}),
                  }}
                >
                  <Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
                    <span
                      style={{
                        width: 12,
                        height: 12,
                        borderRadius: 3,
                        background: slice.color,
                        flexShrink: 0,
                      }}
                    />
                    <Text size="sm" lineClamp={1}>
                      {slice.name}
                    </Text>
                  </Group>
                  <Group gap={8} wrap="nowrap">
                    <Text size="sm" fw={600}>
                      {slice.value}
                    </Text>
                    <Text size="xs" c="dimmed" w={48} ta="right">
                      {pct}%
                    </Text>
                    {slice.id && <DrillChevron />}
                  </Group>
                </Group>
              );
            })}
          </Stack>
        </Modal>
      </Container>
    </PromoThemeProvider>
  );
}
