"use client";

import {
  ActionIcon,
  Alert,
  Badge,
  Box,
  Button,
  Card,
  Container,
  Group,
  MultiSelect,
  Select,
  SimpleGrid,
  Skeleton,
  Stack,
  Table,
  Tabs,
  Text,
  TextInput,
  Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { DatePickerInput } from "@mantine/dates";
import {
  IconArrowLeft,
  IconCake,
  IconCalendarStats,
  IconLogin,
  IconSearch,
  IconUserCheck,
  IconUsers,
  IconWorld,
} from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import TablePaginationFooter from "@/components/TablePaginationFooter";
import { useNavigate, useSearchParams } from "react-router";
import { PromoCodeDetailsSection } from "@/routes/promo-codes/_components/PromoCodeDetailsSection";
import { getPromoCodeByCode } from "@/lib/features/promo-codes/query";
import { rewardValue } from "@/lib/features/promo-codes/rewards";
import type { PromoCode } from "@/lib/features/promo-codes/types";
import {
  getMemberPointsApi,
  getPromoCodeAnalytics,
  getPromoCodeUsers,
} from "@/lib/features/promo-code-campaigns/query";
import type {
  CampaignAnalytics,
  PromoCodeUserRow,
  PromoCodeUsersResult,
} from "@/lib/features/promo-code-campaigns/types";
import type { AccessScope } from "@/lib/features/types";
import { UserBookingsAnalyticsCard } from "@/routes/promo-code-campaigns/_components/UserBookingsAnalyticsCard";
import { HScrollTable } from "@/lib/components/HScrollTable";
import {
  ActiveFilterChips,
  type FilterChip,
  FilterDrawer,
  FilterSection,
  FilterTrigger,
} from "@/routes/promo-code-campaigns/_components/FilterDrawer";
import {
  StatTiles,
  type StatTileSpec,
} from "@/routes/promo-code-campaigns/_components/StatTiles";
import {
  AnimatedNumber,
  PageArrival,
  PageSection,
} from "@/routes/promo-code-campaigns/_components/motion";
import { usePromoTheme } from "@/routes/promo-code-campaigns/_components/appearance";
import { CountryCampaignBars } from "@/routes/promo-code-campaigns/_components/charts/CountryCampaignBars";
import { CountryCampaignLines } from "@/routes/promo-code-campaigns/_components/charts/CountryCampaignLines";
import promoStyles from "@/routes/promo-code-campaigns/_components/promo.module.css";
import {
  DEFAULT_SIGNUP_WINDOW,
  isWindowActive,
  resolveSignupWindow,
  SignupWindowControl,
  type SignupWindowState,
} from "@/routes/reports/_shared";
import { ExportButton } from "@/lib/export/ExportButton";
import { useSessionStorageState } from "@/hooks/useSessionStorageState";
import {
  ExportLimitError,
  MAX_EXPORT_ROWS,
  withTimeout,
} from "@/lib/export/exportLimits";

interface Props {
  // Optional: when opened from the promo-codes list (a code not viewed in a
  // campaign context), there is no campaign. Back then returns to the list and
  // per-member drill-down rows are non-clickable (that route is campaign-only).
  campaignId?: string;
  promoCodeId: string;
  initialPromoCode: PromoCode | null;
  initialAnalytics: CampaignAnalytics | null;
  initialUsers: PromoCodeUsersResult;
  initialError?: string;
  accessScope: AccessScope;
}

const getPromoIdForUpdate = (p: PromoCode) =>
  (p.id as string) ||
  (p.promoCodeId as string) ||
  (p.promo_code_id as string) ||
  (p.code as string) ||
  "";

const USERS_PAGE_SIZE_OPTIONS = ["10", "25", "50", "100"];

const USER_SORT_OPTIONS = [
  { value: "commence-desc", label: "Newest joined" },
  { value: "commence-asc", label: "Oldest joined" },
  { value: "name-asc", label: "Name (A–Z)" },
  { value: "name-desc", label: "Name (Z–A)" },
  { value: "membership-asc", label: "Membership # (ascending)" },
  { value: "membership-desc", label: "Membership # (descending)" },
];

export default function PromoCodeDetailsClientPage({
  campaignId,
  promoCodeId,
  initialPromoCode,
  initialAnalytics,
  initialUsers,
  initialError,
  accessScope,
}: Props) {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  // Where to return on Back. Entry points (Campaigns / Promo Codes list /
  // Member Referrals) pass `?from=<path>`; fall back to the campaign or the
  // promo-codes list. `withFrom` carries the origin into deeper drill-downs.
  const fromParam = searchParams.get("from");
  const backTo =
    fromParam ||
    (campaignId
      ? `/admin/promo-code-campaigns/${campaignId}`
      : "/admin/promo-codes");
  const withFrom = (url: string) =>
    fromParam ? `${url}?from=${encodeURIComponent(fromParam)}` : url;
  const [promo, setPromo] = useState<PromoCode | null>(initialPromoCode);

  /*
   * Points per member for this code.
   *
   * This was a local copy that compared `type.toUpperCase() === "POINTS"`, so a
   * reward stored as "point"/"PT" resolved to null and the Points Allocated
   * column rendered "—" for a code that does grant points. `rewardValue` matches
   * the accepted spellings and is the same helper the campaigns dashboard uses,
   * so the two pages can't disagree.
   */
  const pointsAllocated = rewardValue(promo, "points");
  const [analytics, setAnalytics] = useState(initialAnalytics);
  const [users, setUsers] = useState<PromoCodeUsersResult>(initialUsers);
  const [usersLoading, setUsersLoading] = useState(false);
  const [loggedInLoading, setLoggedInLoading] = useState(false);
  // Scope all persisted UI state to this promo code so each detail page keeps
  // its own tab / search / filters independently of other promo codes.
  const pk = (key: string) => `${key}:${promoCodeId}`;
  const [activeTab, setActiveTab] = useSessionStorageState<string | null>(
    pk("promoDetail.tab"),
    "details",
  );
  const [search, setSearch] = useSessionStorageState(
    pk("promoDetail.search"),
    "",
  );
  const [usersPageSize, setUsersPageSize] = useState(
    initialUsers.pagination.pageSize || 100,
  );
  const [loggedInPageSize, setLoggedInPageSize] = useState(
    initialUsers.pagination.pageSize || 100,
  );

  // "Logged in" tab — only members who have logged in (first_access_attempt
  // IS TRUE). Server-filtered + paginated independently of the Registered list.
  const [loggedInUsers, setLoggedInUsers] = useState<PromoCodeUsersResult>({
    items: [],
    pagination: {
      page: 1,
      pageSize: initialUsers.pagination.pageSize,
      total: 0,
      totalPages: 1,
    },
  });
  const [loggedInSearch, setLoggedInSearch] = useSessionStorageState(
    pk("promoDetail.loggedInSearch"),
    "",
  );

  // Reads members.first_access_attempt (boolean, or its stringified form when
  // the driver returns text) and resolves it to a logged-in / logged-out flag.
  const isLoggedIn = (u: PromoCodeUserRow) => {
    const v = u.first_access_attempt;
    if (typeof v === "boolean") return v;
    if (typeof v === "string") {
      const s = v.trim().toLowerCase();
      return s === "true" || s === "t" || s === "1";
    }
    return Boolean(v);
  };

  // ---- committed filters (persisted across navigation via sessionStorage) ----
  const [userStatusFilter, setUserStatusFilter] = useSessionStorageState<
    string | null
  >(pk("promoDetail.userStatus"), "all");
  const [userCountryFilter, setUserCountryFilter] = useSessionStorageState<
    string[]
  >(pk("promoDetail.userCountry"), []);
  const [userNationalityFilter, setUserNationalityFilter] =
    useSessionStorageState<string[]>(pk("promoDetail.userNationality"), []);
  const [userAccountTypeFilter, setUserAccountTypeFilter] =
    useSessionStorageState<string[]>(pk("promoDetail.userAccountType"), []);
  const [userCommenceRange, setUserCommenceRange] = useSessionStorageState<
    [Date | null, Date | null]
  >(pk("promoDetail.userCommence"), [null, null]);
  const [userDobRange, setUserDobRange] = useSessionStorageState<
    [Date | null, Date | null]
  >(pk("promoDetail.userDob"), [null, null]);
  const [userSortBy, setUserSortBy] = useSessionStorageState<string | null>(
    pk("promoDetail.userSort"),
    "commence-desc",
  );

  const [signupWindow, setSignupWindow] =
    useSessionStorageState<SignupWindowState>(
      pk("promoDetail.window"),
      DEFAULT_SIGNUP_WINDOW,
    );
  const [hoveredDay, setHoveredDay] = useState<number | null>(null);
  const [hoveredBar, setHoveredBar] = useState<number | null>(null);

  const [fetchedPointsMap, setFetchedPointsMap] = useState<
    Record<string, number>
  >({});
  const [fetchingMemberNos, setFetchingMemberNos] = useState<Set<string>>(
    new Set(),
  );

  const handleFetchMemberPoints = async (membershipNo: string) => {
    if (!membershipNo || fetchingMemberNos.has(membershipNo)) return;
    setFetchingMemberNos((prev) => new Set(prev).add(membershipNo));
    try {
      const res = await getMemberPointsApi(membershipNo);
      if (res.success && res.data) {
        setFetchedPointsMap((prev) => ({
          ...prev,
          [membershipNo]: res.data.balance,
        }));
      } else {
        notifications.show({
          color: "red",
          message: res.message || "Failed to fetch member points",
        });
      }
    } catch (err: any) {
      notifications.show({
        color: "red",
        message: err?.message || "Failed to fetch member points",
      });
    } finally {
      setFetchingMemberNos((prev) => {
        const next = new Set(prev);
        next.delete(membershipNo);
        return next;
      });
    }
  };

  const resetUserFilters = () => {
    setUserStatusFilter("all");
    setUserCountryFilter([]);
    setUserNationalityFilter([]);
    setUserAccountTypeFilter([]);
    setUserCommenceRange([null, null]);
    setUserDobRange([null, null]);
    setSignupWindow(DEFAULT_SIGNUP_WINDOW);
    void refreshAnalytics(DEFAULT_SIGNUP_WINDOW);
  };

  const [filtersOpen, setFiltersOpen] = useState(false);

  /* One chip per applied filter, so a narrowed list is never mistaken for the
   * full one once the drawer is closed. */
  const filterChips = useMemo<FilterChip[]>(() => {
    const chips: FilterChip[] = [];
    if (isWindowActive(signupWindow)) {
      chips.push({
        key: "window",
        label: "Signup window",
        onRemove: () => setAnalyticsWindow(DEFAULT_SIGNUP_WINDOW),
      });
    }
    if (userStatusFilter && userStatusFilter !== "all") {
      chips.push({
        key: "status",
        label: `Status: ${userStatusFilter === "active" ? "Active" : "Non-active"}`,
        onRemove: () => setUserStatusFilter("all"),
      });
    }
    const multi: Array<[string, string[], (next: string[]) => void]> = [
      ["Country", userCountryFilter, setUserCountryFilter],
      ["Nationality", userNationalityFilter, setUserNationalityFilter],
      ["Account type", userAccountTypeFilter, setUserAccountTypeFilter],
    ];
    for (const [label, values, setter] of multi) {
      for (const value of values) {
        chips.push({
          key: `${label}:${value}`,
          label: `${label}: ${value}`,
          onRemove: () => setter(values.filter((v) => v !== value)),
        });
      }
    }
    if (userCommenceRange[0] || userCommenceRange[1]) {
      chips.push({
        key: "commence",
        label: "Acquisition date",
        onRemove: () => setUserCommenceRange([null, null]),
      });
    }
    if (userDobRange[0] || userDobRange[1]) {
      chips.push({
        key: "dob",
        label: "Date of birth",
        onRemove: () => setUserDobRange([null, null]),
      });
    }
    return chips;
  }, [
    signupWindow,
    userStatusFilter,
    userCountryFilter,
    userNationalityFilter,
    userAccountTypeFilter,
    userCommenceRange,
    userDobRange,
  ]);

  const setAnalyticsWindow = (next: SignupWindowState) => {
    setSignupWindow(next);
    void refreshAnalytics(next);
  };

  const refreshAnalytics = async (win: SignupWindowState) => {
    const { from, to } = resolveSignupWindow(win);
    const res = await getPromoCodeAnalytics(promoCodeId, { from, to });
    if (res.success) setAnalytics(res.data);
  };

  useEffect(() => {
    void refreshAnalytics(signupWindow);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [promoCodeId]);

  const refreshUsers = async (
    page: number,
    q: string,
    size = usersPageSize,
  ) => {
    setUsersLoading(true);
    try {
      const res = await getPromoCodeUsers(promoCodeId, {
        page,
        pageSize: size,
        search: q || undefined,
      });
      if (res.success) setUsers(res.data);
    } finally {
      setUsersLoading(false);
    }
  };

  const handleUsersPageSizeChange = (v: string | null) => {
    const size = Number(v) || 100;
    setUsersPageSize(size);
    void refreshUsers(1, search, size);
  };

  const refreshLoggedInUsers = async (
    page: number,
    q: string,
    size = loggedInPageSize,
  ) => {
    setLoggedInLoading(true);
    try {
      const res = await getPromoCodeUsers(promoCodeId, {
        page,
        pageSize: size,
        search: q || undefined,
        loggedIn: true,
      });
      if (res.success) setLoggedInUsers(res.data);
    } finally {
      setLoggedInLoading(false);
    }
  };

  const handleLoggedInPageSizeChange = (v: string | null) => {
    const size = Number(v) || 100;
    setLoggedInPageSize(size);
    void refreshLoggedInUsers(1, loggedInSearch, size);
  };

  // Debounced auto-search. Skip the first run — the loader already provided
  // page 1, so refetching on mount would just flash the skeleton for nothing.
  const firstUserSearch = useRef(true);
  useEffect(() => {
    if (firstUserSearch.current) {
      firstUserSearch.current = false;
      if (!search) return;
    }
    const t = setTimeout(() => {
      refreshUsers(1, search);
    }, 350);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [search]);

  // Debounced auto-search for the Logged in tab. Also runs on mount (empty
  // query) so the tab's count is populated without waiting for a tab switch.
  useEffect(() => {
    const t = setTimeout(() => {
      refreshLoggedInUsers(1, loggedInSearch);
    }, 350);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loggedInSearch]);

  const userCountries = useMemo(
    () =>
      Array.from(
        new Set(
          users.items.map((u) => (u.country as string) ?? "").filter(Boolean),
        ),
      ).sort(),
    [users.items],
  );
  const userAccountTypes = useMemo(
    () =>
      Array.from(
        new Set(
          users.items
            .map((u) => (u.account_type_name as string) ?? "")
            .filter(Boolean),
        ),
      ).sort(),
    [users.items],
  );
  const userNationalities = useMemo(
    () =>
      Array.from(
        new Set(
          users.items
            .map((u) => (u.nationality as string) ?? "")
            .filter(Boolean),
        ),
      ).sort(),
    [users.items],
  );

  const windowRange = useMemo(
    () => resolveSignupWindow(signupWindow),
    [signupWindow],
  );

  // One predicate shared by the Registered + Logged-in lists (and therefore the
  // charts + count labels) so every filter — including the "Signups in" window,
  // matched against the acquisition date — narrows all of them identically.
  const userMatchesFilters = useCallback(
    (u: PromoCodeUserRow) => {
      const status = ((u.account_status_name as string) ?? "").toLowerCase();
      if (userStatusFilter === "active" && status !== "active") return false;
      if (userStatusFilter === "inactive" && status === "active") return false;
      if (
        userCountryFilter.length > 0 &&
        !userCountryFilter.includes((u.country as string) ?? "")
      )
        return false;
      if (
        userNationalityFilter.length > 0 &&
        !userNationalityFilter.includes((u.nationality as string) ?? "")
      )
        return false;
      if (
        userAccountTypeFilter.length > 0 &&
        !userAccountTypeFilter.includes((u.account_type_name as string) ?? "")
      )
        return false;
      if (userDobRange[0] && u.date_of_birth) {
        const from = new Date(userDobRange[0] as unknown as string).getTime();
        if (
          Number.isFinite(from) &&
          new Date(String(u.date_of_birth)).getTime() < from
        )
          return false;
      }
      if (userDobRange[1] && u.date_of_birth) {
        const to = new Date(userDobRange[1] as unknown as string);
        if (Number.isFinite(to.getTime())) {
          to.setHours(23, 59, 59, 999);
          if (new Date(String(u.date_of_birth)).getTime() > to.getTime())
            return false;
        }
      }
      if (userCommenceRange[0] && u.commence_date) {
        const from = new Date(
          userCommenceRange[0] as unknown as string,
        ).getTime();
        if (
          Number.isFinite(from) &&
          new Date(String(u.commence_date)).getTime() < from
        )
          return false;
      }
      if (userCommenceRange[1] && u.commence_date) {
        const to = new Date(userCommenceRange[1] as unknown as string);
        if (Number.isFinite(to.getTime())) {
          to.setHours(23, 59, 59, 999);
          if (new Date(String(u.commence_date)).getTime() > to.getTime())
            return false;
        }
      }
      // "Signups in" window — matched against the acquisition date (the field the
      // day chart buckets on) so table, charts and counts react together.
      if (isWindowActive(signupWindow)) {
        if (!u.commence_date) return false;
        const t = new Date(String(u.commence_date)).getTime();
        if (Number.isFinite(t)) {
          if (windowRange.from && t < new Date(windowRange.from).getTime())
            return false;
          if (windowRange.to) {
            const to = new Date(windowRange.to);
            to.setHours(23, 59, 59, 999);
            if (Number.isFinite(to.getTime()) && t > to.getTime()) return false;
          }
        }
      }
      return true;
    },
    [
      userStatusFilter,
      userCountryFilter,
      userNationalityFilter,
      userAccountTypeFilter,
      userDobRange,
      userCommenceRange,
      signupWindow,
      windowRange,
    ],
  );

  const sortUsers = useCallback(
    (list: PromoCodeUserRow[]) =>
      [...list].sort((a, b) => {
        const aDate = a.commence_date
          ? new Date(String(a.commence_date)).getTime()
          : 0;
        const bDate = b.commence_date
          ? new Date(String(b.commence_date)).getTime()
          : 0;
        switch (userSortBy) {
          case "commence-asc":
            return aDate - bDate;
          case "name-asc":
            return `${a.first_name ?? ""} ${a.last_name ?? ""}`.localeCompare(
              `${b.first_name ?? ""} ${b.last_name ?? ""}`,
            );
          case "name-desc":
            return `${b.first_name ?? ""} ${b.last_name ?? ""}`.localeCompare(
              `${a.first_name ?? ""} ${a.last_name ?? ""}`,
            );
          case "membership-asc":
            return String(a.membership_number ?? "").localeCompare(
              String(b.membership_number ?? ""),
            );
          case "membership-desc":
            return String(b.membership_number ?? "").localeCompare(
              String(a.membership_number ?? ""),
            );
          case "commence-desc":
          default:
            return bDate - aDate;
        }
      }),
    [userSortBy],
  );

  const visibleUsers = useMemo(
    () => sortUsers(users.items.filter(userMatchesFilters)),
    [users.items, userMatchesFilters, sortUsers],
  );

  const visibleLoggedInUsers = useMemo(
    () => sortUsers(loggedInUsers.items.filter(userMatchesFilters)),
    [loggedInUsers.items, userMatchesFilters, sortUsers],
  );

  /*
   * Signups by country, from the server-side aggregate.
   *
   * This used to count `visibleUsers`, which is only the *loaded page* of
   * members — the users endpoint caps pageSize at 100. Any code with more than
   * 100 signups would therefore have charted just the first 100 while the KPI
   * tiles above (which use `pagination.total`) described all of them, with
   * nothing on screen explaining the disagreement. No code exceeds 100 today, so
   * this was latent rather than visible, but it would break silently the moment
   * one did.
   *
   * `byCountryPromoCode` covers every signup and already honours the signup
   * window, which is applied in SQL.
   */
  const countryBarData = useMemo(() => {
    const byCountry: Record<string, number> = {};
    for (const row of analytics?.byCountryPromoCode ?? []) {
      const country = String(row.country ?? "").trim() || "Unknown";
      // Country is a dimension of the aggregate, so this filter stays exact.
      if (
        userCountryFilter.length > 0 &&
        !userCountryFilter.includes(String(row.country ?? ""))
      ) {
        continue;
      }
      byCountry[country] = (byCountry[country] ?? 0) + Number(row.signups || 0);
    }
    return Object.entries(byCountry)
      .filter(([, count]) => count > 0)
      .map(([country, count]) => ({
        country,
        Signups: count,
      })) as Array<Record<string, string | number>>;
  }, [analytics, userCountryFilter]);

  const { series: seriesPalette } = usePromoTheme();

  /*
   * A single-series palette entry shared by both charts, so signups read as one
   * measure whichever chart you're looking at.
   */
  const signupSeries = useMemo(() => {
    const [paint] = seriesPalette(1);
    return [
      {
        key: "signups",
        name: "Signups",
        color: paint.from,
        colorTo: paint.to,
      },
    ];
  }, [seriesPalette]);

  /*
   * Signups per day, chronologically, from the server-side aggregate.
   *
   * Two problems with the previous version, which counted `visibleUsers`:
   *
   * 1. It saw only the loaded page (pageSize caps at 100), so a code with more
   *    signups than that would have charted a subset while the tiles above
   *    described the whole code.
   * 2. It bucketed by `commence_date`, but the signup-window filter — and every
   *    other total on this page — narrows on `created_at`. Selecting "last 7
   *    days" could therefore plot days outside the window entirely.
   *
   * `byDay` is grouped on `created_at` in SQL over every signup, so the chart now
   * agrees with the window filter and with the campaign page's own day chart.
   */
  const dayRows = useMemo(
    () =>
      (analytics?.byDay ?? []).map((row) => ({
        day: new Date(`${row.day}T00:00:00Z`).toLocaleDateString(undefined, {
          day: "2-digit",
          month: "short",
        }),
        Signups: Number(row.signups || 0),
      })),
    [analytics],
  );

  /*
   * Headline figures for this promo code.
   *
   * Registered and logged-in come from the server-side pagination totals, not
   * the loaded page, so they describe the whole code rather than whatever rows
   * happen to be fetched. Points follow the granting rule: a reward lands only
   * once a member logs in, so the total is `per-member × logged-in` — never
   * × registered.
   */
  /*
   * Filters that narrow the member table but cannot narrow the charts.
   *
   * The two charts read server-side aggregates, which are grouped by day and by
   * country only — there is no status/nationality/account-type/DOB dimension to
   * filter on. Rather than quietly showing unfiltered charts next to a filtered
   * table, the page says so.
   */
  const chartOnlyFilterNote = useMemo(() => {
    const ignored: string[] = [];
    if (userStatusFilter && userStatusFilter !== "all") ignored.push("status");
    if (userNationalityFilter.length > 0) ignored.push("nationality");
    if (userAccountTypeFilter.length > 0) ignored.push("account type");
    if (userDobRange[0] || userDobRange[1]) ignored.push("date of birth");
    if (userCommenceRange[0] || userCommenceRange[1]) {
      ignored.push("acquisition date");
    }
    return ignored.length > 0 ? ignored.join(", ") : null;
  }, [
    userStatusFilter,
    userNationalityFilter,
    userAccountTypeFilter,
    userDobRange,
    userCommenceRange,
  ]);

  const statTiles = useMemo<StatTileSpec[]>(() => {
    const registered = users.pagination.total;
    const loggedIn = loggedInUsers.pagination.total;
    const pending = Math.max(0, registered - loggedIn);
    const paint = seriesPalette(5);
    const tiles: StatTileSpec[] = [
      {
        key: "registered",
        label: "Registered",
        value: registered,
        icon: <IconUsers size={17} />,
        color: paint[0].from,
        colorTo: paint[0].to,
        hint: "Signed up with this code",
      },
      {
        key: "logged-in",
        label: "Logged in",
        value: loggedIn,
        icon: <IconLogin size={17} />,
        color: paint[1].from,
        colorTo: paint[1].to,
        hint: "Rewards granted",
        percent: registered > 0 ? (loggedIn / registered) * 100 : 0,
      },
      {
        key: "pending",
        label: "Pending login",
        value: pending,
        icon: <IconUserCheck size={17} />,
        color: paint[2].from,
        colorTo: paint[2].to,
        hint: "Nothing granted yet",
        percent: registered > 0 ? (pending / registered) * 100 : 0,
      },
      {
        key: "countries",
        label: "Countries",
        value: countryBarData.length,
        icon: <IconWorld size={17} />,
        color: paint[3].from,
        colorTo: paint[3].to,
        hint: "In current filters",
      },
    ];
    // Only shown when the code actually carries a points reward — a dash would
    // be indistinguishable from a genuine zero in a KPI tile.
    if (pointsAllocated != null) {
      tiles.push({
        key: "points",
        label: "Points allocated",
        value: pointsAllocated * loggedIn,
        icon: <IconCalendarStats size={17} />,
        color: paint[4].from,
        colorTo: paint[4].to,
        hint: `${pointsAllocated.toLocaleString()} × ${loggedIn.toLocaleString()} logged in`,
      });
    }
    return tiles;
  }, [
    users.pagination.total,
    loggedInUsers.pagination.total,
    countryBarData.length,
    pointsAllocated,
    seriesPalette,
  ]);

  // If Updot doesn't have details for this code (404), we still want to show
  // the analytics + users sections — those come from our member DB and are
  // independent of Updot's promo_codes lookup. Fall back to URL value.
  const effectivePromo: PromoCode =
    promo ?? ({ code: promoCodeId, name: promoCodeId } as PromoCode);
  void effectivePromo;

  // Shared table body for the Registered + Logged in tabs. Each row drills into
  // the member; the "Logged In?" column reflects members.first_access_attempt.
  const renderUserRows = (items: PromoCodeUserRow[], showBookings = false) =>
    items.length === 0 ? (
      <Table.Tr>
        <Table.Td colSpan={showBookings ? 13 : 11}>
          <Text c="dimmed" ta="center" py="lg">
            No users match the filters.
          </Text>
        </Table.Td>
      </Table.Tr>
    ) : (
      items.map((u: PromoCodeUserRow, idx) => {
        const membership = (u.membership_number as string) ?? "";
        // Drill into the member — campaign-scoped route when in a campaign,
        // else the standalone promo-codes member route.
        const memberHref = membership
          ? withFrom(
              campaignId
                ? `/admin/promo-code-campaigns/${campaignId}/promo-codes/${encodeURIComponent(promoCodeId)}/members/${encodeURIComponent(membership)}`
                : `/admin/promo-codes/${encodeURIComponent(promoCodeId)}/members/${encodeURIComponent(membership)}`,
            )
          : null;
        const loggedIn = isLoggedIn(u);
        return (
          <Table.Tr
            key={membership || idx}
            className={promoStyles.row}
            /* `.row` assumes a clickable row; rows with no member link have to
             * put the default cursor back. */
            style={memberHref ? undefined : { cursor: "default" }}
            onClick={() => {
              if (memberHref) navigate(memberHref);
            }}
          >
            <Table.Td>{(u.membership_number as string) ?? "—"}</Table.Td>
            <Table.Td>
              {[(u.first_name as string) ?? "", (u.last_name as string) ?? ""]
                .filter(Boolean)
                .join(" ") || "—"}
            </Table.Td>
            <Table.Td>{(u.email as string) ?? "—"}</Table.Td>
            <Table.Td>{(u.phone as string) || "—"}</Table.Td>
            <Table.Td>
              {u.date_of_birth
                ? new Date(String(u.date_of_birth)).toLocaleDateString()
                : "—"}
            </Table.Td>
            <Table.Td>{(u.country as string) ?? "—"}</Table.Td>
            <Table.Td>{(u.nationality as string) || "—"}</Table.Td>
            <Table.Td>{(u.account_type_name as string) ?? "—"}</Table.Td>
            {/* <Table.Td>
              <Badge color={loggedIn ? "teal" : "gray"} variant="light">
                {loggedIn ? "Logged in" : "Not logged in"}
              </Badge>
            </Table.Td> */}
            <Table.Td>
              {u.commence_date
                ? new Date(String(u.commence_date)).toLocaleDateString()
                : "—"}
            </Table.Td>
            <Table.Td>
              {/* Points are only granted once a member logs in. */}
              {loggedIn ? (pointsAllocated != null ? pointsAllocated : "—") : 0}
            </Table.Td>
            <Table.Td onClick={(e) => e.stopPropagation()}>
              {fetchedPointsMap[membership] !== undefined ? (
                <Badge variant="light" size="md">
                  {fetchedPointsMap[membership]} pts
                </Badge>
              ) : membership ? (
                <Button
                  size="xs"
                  variant="light"
                  loading={fetchingMemberNos.has(membership)}
                  onClick={(e) => {
                    e.stopPropagation();
                    void handleFetchMemberPoints(membership);
                  }}
                >
                  Fetch
                </Button>
              ) : (
                "—"
              )}
            </Table.Td>
            {showBookings && (
              <>
                <Table.Td className={promoStyles.num}>
                  <span className={promoStyles.dataBadge} data-role="internal">
                    {Number(u.internal_bookings_count ?? 0)}
                  </span>
                </Table.Td>
                <Table.Td className={promoStyles.num}>
                  <span className={promoStyles.dataBadge} data-role="curated">
                    {Number(u.external_bookings_count ?? 0)}
                  </span>
                </Table.Td>
              </>
            )}
          </Table.Tr>
        );
      })
    );

  // Skeleton rows shown in the users tables while a page/search refresh is in
  // flight — keeps the table height stable instead of flashing empty.
  const renderUserTableSkeleton = (cols = 11) =>
    Array.from({ length: 6 }).map((_, i) => (
      <Table.Tr key={`sk-${i}`}>
        {Array.from({ length: cols }).map((__, j) => (
          <Table.Td key={j}>
            <Skeleton height={16} radius="sm" />
          </Table.Td>
        ))}
      </Table.Tr>
    ));

  const getExportData = async (signal: AbortSignal) => {
    const all: PromoCodeUserRow[] = [];
    const pageSize = 100;
    let page = 1;
    let totalPages = 1;
    do {
      if (signal.aborted)
        throw new DOMException("Export cancelled", "AbortError");
      const res = await withTimeout(
        getPromoCodeUsers(promoCodeId, {
          page,
          pageSize,
          search: search || undefined,
        }),
      );
      if (!res.success) throw new Error(res.message || "Failed to load users");
      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((u, i) => {
      const loggedIn = isLoggedIn(u);
      return {
        "#": i + 1,
        "Membership #": String(u.membership_number ?? ""),
        Name: [u.first_name, u.last_name].filter(Boolean).join(" "),
        Email: String(u.email ?? ""),
        Country: String(u.country ?? ""),
        "Account Type": String(u.account_type_name ?? ""),
        "Logged In": loggedIn ? "Yes" : "No",
        "Date of Acquisition": u.commence_date
          ? new Date(String(u.commence_date)).toLocaleDateString()
          : "",
      };
    });
    return {
      sheetName: "Promo Code Users",
      columns: [
        { key: "#" as const, label: "#", width: 6 },
        { key: "Membership #" as const, label: "Membership #", width: 16 },
        { key: "Name" as const, label: "Name", width: 26 },
        { key: "Email" as const, label: "Email", width: 30 },
        { key: "Country" as const, label: "Country", width: 16 },
        { key: "Account Type" as const, label: "Account Type", width: 18 },
        { key: "Logged In" as const, label: "Logged In", width: 10 },
        {
          key: "Date of Acquisition" as const,
          label: "Date of Acquisition",
          width: 18,
        },
      ],
      rows,
    };
  };

  return (
    <PageArrival>
      <Container fluid px="xl" py="xl">
        <Group justify="space-between" mb="lg">
          <Group>
            <ActionIcon variant="subtle" onClick={() => navigate(backTo)}>
              <IconArrowLeft size={18} />
            </ActionIcon>
            <Stack gap={0}>
              <Title order={2}>
                {String(
                  effectivePromo.name ?? effectivePromo.code ?? promoCodeId,
                )}
              </Title>
              {effectivePromo.code && effectivePromo.name && (
                <Text size="xs" c="dimmed" tt="uppercase">
                  Code: {String(effectivePromo.code)}
                </Text>
              )}
            </Stack>
            {typeof effectivePromo.isActive === "boolean" ||
            typeof effectivePromo.is_active === "boolean" ? (
              <Badge
                color={
                  (effectivePromo.isActive ?? effectivePromo.is_active)
                    ? "green"
                    : "gray"
                }
              >
                {(effectivePromo.isActive ?? effectivePromo.is_active)
                  ? "Active"
                  : "Inactive"}
              </Badge>
            ) : null}
          </Group>
          <Group gap="xs" align="center">
            <FilterTrigger
              activeCount={filterChips.length}
              onClick={() => setFiltersOpen(true)}
            />
            <ExportButton
              label="Export Promo Code User Data"
              filename={`promo-code-${promoCodeId}-users`}
              getData={getExportData}
              /* Its own section, not campaigns: this sheet is member personal
                 data, while the campaign exports are code metadata. Sharing one
                 switch meant granting campaign export also granted member PII. */
              section="promoCodeMembers"
            />
          </Group>
        </Group>

        {!promo && (
          <Alert color="yellow" mb="md" variant="light">
            Couldn't load promo code details from Updot (
            {initialError ?? "not found"}). Showing analytics + users only.
          </Alert>
        )}

        <ActiveFilterChips chips={filterChips} onReset={resetUserFilters} />

        <PageSection>
          <StatTiles
            tiles={statTiles}
            loading={usersLoading && !users.items.length}
          />
        </PageSection>

        <FilterDrawer
          opened={filtersOpen}
          onClose={() => setFiltersOpen(false)}
          activeCount={filterChips.length}
          onReset={resetUserFilters}
        >
          <FilterSection
            icon={<IconCalendarStats size={15} />}
            title="Signup window"
            description="Limits the members counted in every chart below."
          >
            <SignupWindowControl
              value={signupWindow}
              onChange={setAnalyticsWindow}
            />
          </FilterSection>

          <FilterSection
            icon={<IconUsers size={15} />}
            title="Member"
            description="Narrows the member list and both charts."
          >
            <Select
              label="Status"
              value={userStatusFilter ?? "all"}
              onChange={(v) => setUserStatusFilter(v ?? "all")}
              allowDeselect={false}
              data={[
                { value: "all", label: "All" },
                { value: "active", label: "Active" },
                { value: "inactive", label: "Non-active" },
              ]}
            />
            <MultiSelect
              label="Country"
              placeholder={userCountryFilter.length ? undefined : "Any country"}
              value={userCountryFilter}
              onChange={setUserCountryFilter}
              data={userCountries.map((c) => ({ value: c, label: c }))}
              clearable
              searchable
              hidePickedOptions
            />
            <MultiSelect
              label="Nationality"
              placeholder={
                userNationalityFilter.length ? undefined : "Any nationality"
              }
              value={userNationalityFilter}
              onChange={setUserNationalityFilter}
              data={userNationalities.map((c) => ({ value: c, label: c }))}
              clearable
              searchable
              hidePickedOptions
            />
            <MultiSelect
              label="Account type"
              placeholder={
                userAccountTypeFilter.length ? undefined : "Any account type"
              }
              value={userAccountTypeFilter}
              onChange={setUserAccountTypeFilter}
              data={userAccountTypes.map((c) => ({ value: c, label: c }))}
              clearable
              searchable
              hidePickedOptions
            />
          </FilterSection>

          <FilterSection
            icon={<IconCake size={15} />}
            title="Dates"
            description="Bounds on when a member joined, and their date of birth."
            withDivider={false}
          >
            <Stack gap={4}>
              <Text size="sm" fw={500}>
                Date of acquisition
              </Text>
              {/* Acquisition is a past event, so no future dates; the ends also
                  bound each other to block an inverted range. */}
              <Group gap="xs" grow>
                <DatePickerInput
                  placeholder="From"
                  maxDate={userCommenceRange[1] ?? new Date()}
                  value={userCommenceRange[0]}
                  onChange={(val) =>
                    setUserCommenceRange(([_, end]) => [
                      val as Date | null,
                      end,
                    ])
                  }
                  clearable
                  valueFormat="DD MMM YYYY"
                />
                <DatePickerInput
                  placeholder="To"
                  minDate={userCommenceRange[0] ?? undefined}
                  maxDate={new Date()}
                  value={userCommenceRange[1]}
                  onChange={(val) =>
                    setUserCommenceRange(([start]) => [
                      start,
                      val as Date | null,
                    ])
                  }
                  clearable
                  valueFormat="DD MMM YYYY"
                />
              </Group>
            </Stack>
            <Stack gap={4}>
              <Text size="sm" fw={500}>
                Date of birth
              </Text>
              <Group gap="xs" grow>
                <DatePickerInput
                  placeholder="From"
                  maxDate={userDobRange[1] ?? new Date()}
                  value={userDobRange[0]}
                  onChange={(val) =>
                    setUserDobRange(([_, end]) => [val as Date | null, end])
                  }
                  clearable
                  valueFormat="DD MMM YYYY"
                />
                <DatePickerInput
                  placeholder="To"
                  minDate={userDobRange[0] ?? undefined}
                  maxDate={new Date()}
                  value={userDobRange[1]}
                  onChange={(val) =>
                    setUserDobRange(([start]) => [start, val as Date | null])
                  }
                  clearable
                  valueFormat="DD MMM YYYY"
                />
              </Group>
            </Stack>
          </FilterSection>
        </FilterDrawer>

        {/* Analytics Card – User Bookings Analytics & Signups by Day side-by-side, Signups by Country below */}
        <Card className={promoStyles.sectionCard} p="md" mt="md" mb="md">
          <Group justify="space-between" mb="xs" align="flex-start">
            <Text fw={700} size="md">
              Analytics
            </Text>
            <Group gap="sm" align="center">
              {isWindowActive(signupWindow) &&
                (() => {
                  const r = resolveSignupWindow(signupWindow);
                  return (
                    <Text size="xs" c="dimmed">
                      {r.from ? new Date(r.from).toLocaleDateString() : "…"} –{" "}
                      {r.to ? new Date(r.to).toLocaleDateString() : "…"}
                    </Text>
                  );
                })()}
              {/* No explicit colour — follows the theme accent.
                  Counts every signup, not the loaded page, so it agrees with the
                  charts below and the tiles above. */}
              <Badge variant="light" size="lg">
                <AnimatedNumber
                  value={analytics?.total ?? users.pagination.total}
                />{" "}
                total signups
              </Badge>
            </Group>
          </Group>

          {/* Side-by-Side: User Booking Distribution Analytics (Left) & Signups by Day (Right) */}
          <SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" mb="lg">
            <Box pr="md">
              <UserBookingsAnalyticsCard
                users={visibleUsers}
                analytics={analytics}
                totalUsers={visibleUsers.length}
                withCardWrapper={false}
              />
            </Box>

            <Box>
              <Text size="md" fw={700} mb="xs">
                Signups by Day
              </Text>
              {dayRows.length === 0 ? (
                <Text c="dimmed" size="sm">
                  No signups in range.
                </Text>
              ) : (
                <CountryCampaignLines
                  data={dayRows}
                  series={signupSeries}
                  xKey="day"
                  height={220}
                  hoveredIndex={hoveredDay}
                  onHoverIndex={setHoveredDay}
                  /* Chronological: ranking days by volume would leave the line
                   * with no time axis. */
                  preserveOrder
                  pointNoun="days"
                />
              )}
            </Box>
          </SimpleGrid>

          {chartOnlyFilterNote && (
            <Text size="xs" c="dimmed" mt={4}>
              Charts cover every signup in the selected window —{" "}
              {chartOnlyFilterNote} narrows the member list below, not these
              charts.
            </Text>
          )}

          {/* Full-width below: Signups by Country */}
          <Box mt="md">
            <hr className={promoStyles.gradientRule} />
            <Text size="md" fw={700} mt="md" mb="xs">
              Signups by Country
            </Text>
            {countryBarData.length === 0 ? (
              <Text c="dimmed" size="sm">
                No country data in range.
              </Text>
            ) : (
              <CountryCampaignBars
                data={countryBarData}
                series={signupSeries}
                xKey="country"
                height={260}
                hoveredIndex={hoveredBar}
                onHoverIndex={setHoveredBar}
              />
            )}
          </Box>
        </Card>

        {/* Tabs: Promo Code Details (default) + Registered + Logged in */}
        <Tabs value={activeTab} onChange={setActiveTab}>
          <Tabs.List>
            <Tabs.Tab value="details">Promo Code Details</Tabs.Tab>
            <Tabs.Tab value="registered">
              Registered ({users.pagination.total})
            </Tabs.Tab>
            <Tabs.Tab value="logged-in">
              Logged in ({loggedInUsers.pagination.total})
            </Tabs.Tab>
          </Tabs.List>

          <Tabs.Panel value="registered" pt="md">
            <Card withBorder radius="md" p="md">
              <Text fw={700} size="lg" mb="sm">
                Registered ({users.pagination.total})
              </Text>
              <Group
                gap="sm"
                mb="md"
                align="center"
                wrap="wrap"
                justify="space-between"
              >
                <TextInput
                  placeholder="Search by name, email, membership # or phone…"
                  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={userSortBy ?? "commence-desc"}
                  onChange={(v) => setUserSortBy(v ?? "commence-desc")}
                  allowDeselect={false}
                  data={USER_SORT_OPTIONS}
                />
              </Group>
              <HScrollTable minWidth={1200}>
                {/* Hover and cell borders come from promo.module.css; the
                  accent-tinted row hover would fight Mantine's striping. No
                  outer frame — this sits inside a bordered Card already. */}
                <Table stickyHeader style={{ minWidth: 1200 }}>
                  <Table.Thead>
                    <Table.Tr>
                      <Table.Th className={promoStyles.th}>
                        Membership #
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>Name</Table.Th>
                      <Table.Th className={promoStyles.th}>Email</Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Phone Number
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>DOB</Table.Th>
                      <Table.Th className={promoStyles.th}>Country</Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Nationality
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Account Type
                      </Table.Th>
                      {/* <Table.Th className={promoStyles.th}>Logged In?</Table.Th> */}
                      <Table.Th className={promoStyles.th}>
                        Date of Acquisition
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Points Allocated
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Actual Points
                      </Table.Th>
                    </Table.Tr>
                  </Table.Thead>
                  <Table.Tbody>
                    {usersLoading
                      ? renderUserTableSkeleton(11)
                      : renderUserRows(visibleUsers, false)}
                  </Table.Tbody>
                </Table>
              </HScrollTable>
              <TablePaginationFooter
                total={users.pagination.total}
                page={users.pagination.page}
                totalPages={users.pagination.totalPages}
                pageSize={usersPageSize}
                onPageChange={(p) => refreshUsers(p, search)}
                onPageSizeChange={(size) =>
                  handleUsersPageSizeChange(String(size))
                }
                pageSizeOptions={USERS_PAGE_SIZE_OPTIONS}
              />
            </Card>
          </Tabs.Panel>

          <Tabs.Panel value="logged-in" pt="md">
            <Card withBorder radius="md" p="md">
              <Text fw={700} size="lg" mb="sm">
                Logged in ({loggedInUsers.pagination.total})
              </Text>
              <Group
                gap="sm"
                mb="md"
                align="center"
                wrap="wrap"
                justify="space-between"
              >
                <TextInput
                  placeholder="Search by name, email, membership # or phone…"
                  leftSection={<IconSearch size={16} />}
                  value={loggedInSearch}
                  onChange={(e) => setLoggedInSearch(e.currentTarget.value)}
                  style={{ flex: 1, minWidth: 240, maxWidth: 360 }}
                />
                <Select
                  size="sm"
                  variant="filled"
                  w={200}
                  value={userSortBy ?? "commence-desc"}
                  onChange={(v) => setUserSortBy(v ?? "commence-desc")}
                  allowDeselect={false}
                  data={USER_SORT_OPTIONS}
                />
              </Group>
              <HScrollTable minWidth={1200}>
                {/* Hover and cell borders come from promo.module.css; the
                  accent-tinted row hover would fight Mantine's striping. No
                  outer frame — this sits inside a bordered Card already. */}
                <Table stickyHeader style={{ minWidth: 1200 }}>
                  <Table.Thead>
                    <Table.Tr>
                      <Table.Th className={promoStyles.th}>
                        Membership #
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>Name</Table.Th>
                      <Table.Th className={promoStyles.th}>Email</Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Phone Number
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>DOB</Table.Th>
                      <Table.Th className={promoStyles.th}>Country</Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Nationality
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Account Type
                      </Table.Th>
                      {/* <Table.Th className={promoStyles.th}>Logged In?</Table.Th> */}
                      <Table.Th className={promoStyles.th}>
                        Date of Acquisition
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Points Allocated
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Actual Points
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Karma Subito Bookings
                      </Table.Th>
                      <Table.Th className={promoStyles.th}>
                        Curated Bookings
                      </Table.Th>
                    </Table.Tr>
                  </Table.Thead>
                  <Table.Tbody>
                    {loggedInLoading
                      ? renderUserTableSkeleton(13)
                      : renderUserRows(visibleLoggedInUsers, true)}
                  </Table.Tbody>
                </Table>
              </HScrollTable>
              <TablePaginationFooter
                total={loggedInUsers.pagination.total}
                page={loggedInUsers.pagination.page}
                totalPages={loggedInUsers.pagination.totalPages}
                pageSize={loggedInPageSize}
                onPageChange={(p) => refreshLoggedInUsers(p, loggedInSearch)}
                onPageSizeChange={(size) =>
                  handleLoggedInPageSizeChange(String(size))
                }
                pageSizeOptions={USERS_PAGE_SIZE_OPTIONS}
              />
            </Card>
          </Tabs.Panel>

          <Tabs.Panel value="details" pt="md">
            {promo ? (
              <PromoCodeDetailsSection
                promoId={getPromoIdForUpdate(promo) || promoCodeId}
                promo={promo}
                accessScope={accessScope}
                onSaved={async () => {
                  const refetched = await getPromoCodeByCode(promoCodeId);
                  if (refetched.success) {
                    const raw = refetched.data as Record<
                      string,
                      unknown
                    > | null;
                    const unwrapped =
                      raw &&
                      typeof raw === "object" &&
                      "data" in raw &&
                      raw.data &&
                      typeof raw.data === "object" &&
                      !("name" in raw || "code" in raw || "rewards" in raw)
                        ? (raw.data as PromoCode)
                        : (raw as PromoCode | null);
                    if (unwrapped) setPromo(unwrapped);
                  }
                }}
              />
            ) : (
              <Card withBorder radius="md" p="lg">
                <Text c="dimmed">Promo code details are not available.</Text>
              </Card>
            )}
          </Tabs.Panel>
        </Tabs>
      </Container>
    </PageArrival>
  );
}

function DetailRow({ label, value }: { label: string; value: string }) {
  return (
    <Stack gap={2}>
      <Text size="xs" c="dimmed" tt="uppercase">
        {label}
      </Text>
      <Text size="sm" fw={500} style={{ wordBreak: "break-word" }}>
        {value}
      </Text>
    </Stack>
  );
}
