"use client";

import {
  Alert,
  Badge,
  Card,
  Group,
  Loader,
  MultiSelect,
  Select,
  Stack,
  Switch,
  Text,
  TextInput,
  Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { notifications } from "@mantine/notifications";
import { IconSearch } from "@tabler/icons-react";
import { InfoHint } from "@/lib/components/InfoHint";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import {
  getCodeCampaignMap,
  getReportFilterOptions,
  getReportUsers,
  listCountriesFromDB,
  type CodeCampaignMapRow,
} from "@/lib/features/promo-code-campaigns/query";
import type {
  PromoCodeUserRow,
  PromoCodeUsersResult,
} from "@/lib/features/promo-code-campaigns/types";
import { listPromoCodes } from "@/lib/features/promo-codes/query";
import type { PromoCode } from "@/lib/features/promo-codes/types";
import { useRoleAccess } from "@/hooks/useRoleAccess";
import { ExportButton } from "@/lib/export/ExportButton";
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,
  PageSection,
} from "@/routes/promo-code-campaigns/_components/motion";
import { usePromoTheme } from "@/routes/promo-code-campaigns/_components/appearance";
import promoStyles from "@/routes/promo-code-campaigns/_components/promo.module.css";
import {
  IconCake,
  IconCalendarStats,
  IconLogin,
  IconUsers,
  IconWorld,
} from "@tabler/icons-react";
import {
  ExportLimitError,
  MAX_EXPORT_ROWS,
  withTimeout,
} from "@/lib/export/exportLimits";
import { useSessionStorageState } from "@/hooks/useSessionStorageState";
import {
  buildExportSheet,
  ColumnArranger,
  CountryMultiSelect,
  DEFAULT_SIGNUP_WINDOW,
  defaultColumnKeys,
  FilterPanel,
  isWindowActive,
  ReportPreview,
  resolveColumns,
  resolveSignupWindow,
  SelectAllMultiSelect,
  SignupWindowControl,
  type ReportColumn,
  type SignupWindowState,
} from "../_shared";

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

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

// Local-date YYYY-MM-DD for the server-side DOB range (date-only, no timezone).
const toYMD = (d: Date | null): string | undefined => {
  if (!d) return undefined;
  const dt = new Date(d);
  if (!Number.isFinite(dt.getTime())) return undefined;
  const y = dt.getFullYear();
  const m = String(dt.getMonth() + 1).padStart(2, "0");
  const day = String(dt.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
};

// Resolve members.first_access_attempt (boolean or stringified) to a 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);
};

const REPORT_COLUMNS: ReportColumn<PromoCodeUserRow>[] = [
  {
    key: "promo_code",
    label: "Promo Code",
    width: 22,
    defaultOn: true,
    get: (u) => String(u.signup_promo_code ?? ""),
  },
  {
    key: "campaign",
    label: "Campaign",
    width: 22,
    defaultOn: true,
    get: (u) => String(u.campaign ?? ""),
  },
  {
    key: "membership_number",
    label: "Account ID",
    width: 16,
    defaultOn: true,
    get: (u) => String(u.membership_number ?? ""),
  },
  {
    key: "member_number",
    label: "Member ID",
    width: 16,
    defaultOn: false,
    get: (u) => String(u.member_number ?? ""),
  },
  {
    key: "first_name",
    label: "First Name",
    width: 18,
    defaultOn: true,
    get: (u) => String(u.first_name ?? ""),
  },
  {
    key: "last_name",
    label: "Last Name",
    width: 18,
    defaultOn: true,
    get: (u) => String(u.last_name ?? ""),
  },
  {
    key: "email",
    label: "Email",
    width: 30,
    defaultOn: true,
    get: (u) => String(u.email ?? ""),
  },
  {
    key: "phone",
    label: "Phone",
    width: 18,
    defaultOn: true,
    get: (u) => String(u.phone ?? ""),
  },
  {
    key: "nationality",
    label: "Nationality",
    width: 16,
    defaultOn: true,
    get: (u) => String(u.nationality ?? ""),
  },
  {
    key: "country",
    label: "Country",
    width: 16,
    defaultOn: true,
    get: (u) => String(u.country ?? ""),
  },
  {
    key: "date_of_birth",
    label: "Date of Birth",
    width: 16,
    defaultOn: false,
    get: (u) => fmtDate(u.date_of_birth),
  },
  {
    key: "account_type_name",
    label: "Account Type",
    width: 18,
    defaultOn: false,
    get: (u) => String(u.account_type_name ?? ""),
  },
  {
    key: "account_status_name",
    label: "Account Status",
    width: 16,
    defaultOn: false,
    get: (u) => String(u.account_status_name ?? ""),
  },
  {
    key: "logged_in",
    label: "Logged In",
    width: 12,
    defaultOn: false,
    get: (u) => (isLoggedIn(u) ? "Yes" : "No"),
  },
  {
    key: "commence_date",
    label: "Date of Acquisition",
    width: 18,
    defaultOn: true,
    get: (u) => fmtDate(u.commence_date),
  },
];

const DEFAULT_COLUMN_KEYS = defaultColumnKeys(REPORT_COLUMNS);

// Code string matched against members.signup_promo_code.
const codeOf = (p: PromoCode): string => String(p.code ?? p.name ?? "").trim();

/**
 * The account type a non-super-admin's reports are locked to.
 *
 * Spelled as the membership's real name rather than the "KCGM" shorthand, because this
 * is the value core filters on and the same string a super admin sees in the dropdown.
 * Showing an acronym here would leave the two describing the same filter differently.
 */
const DEFAULT_ACCOUNT_TYPE = "KARMA CLUB GUEST MEMBERSHIP";

export default function MemberReport() {
  const navigate = useNavigate();

  const openMember = (u: PromoCodeUserRow) => {
    const code = String(u.signup_promo_code ?? "").trim();
    const membership = String(u.membership_number ?? "").trim();
    if (!code || !membership) return;
    // Stay within the Reports section (own nested route) rather than jumping
    // to the promo-codes pages.
    navigate(
      `/admin/reports/members/${encodeURIComponent(code)}/${encodeURIComponent(membership)}`,
    );
  };

  // Clear any stale dobRange from sessionStorage (it was previously persisted
  // but is now plain useState — remove the leftover key on first mount).
  useEffect(() => {
    try {
      window.sessionStorage.removeItem("reports.dobRange");
    } catch {
      /* ignore */
    }
  }, []);

  // ---- promo-code picker source ----
  const [codeOptions, setCodeOptions] = useState<
    { value: string; label: string }[]
  >([]);
  const [codesLoading, setCodesLoading] = useState(true);
  const [codesError, setCodesError] = useState<string | null>(null);
  // Picker search text — drives a server-side code search.
  const [codeSearch, setCodeSearch] = useState("");
  // Cached labels so already-selected chips keep their label when filtered out.
  const [labelByValue, setLabelByValue] = useSessionStorageState<
    Record<string, string>
  >("reports.labelByValue", {});

  // ---- report configuration (persisted across navigation) ----
  const [selectedCodes, setSelectedCodes] = useSessionStorageState<string[]>(
    "reports.selectedCodes",
    [],
  );
  /**
   * Which population the report covers.
   *
   * A promo code is either a marketing code or a member-referral code, and the two are
   * separate reports in every other part of the console. Reporting on "all promo-code
   * signups" silently blends them, so this makes the choice explicit — with `both` kept
   * as the default, since that is what the page did before.
   */
  const [codeType, setCodeType] = useSessionStorageState<
    "both" | "promo" | "referral"
  >("reports.codeType", "both");

  const [selectedCampaigns, setSelectedCampaigns] = useSessionStorageState<
    string[]
  >("reports.selectedCampaigns", []);
  // Full code↔campaign mapping (core DB), loaded once. Powers the campaign
  // picker, the campaign filter, and the Campaign column.
  const [mapRows, setMapRows] = useState<CodeCampaignMapRow[]>([]);
  const [mapLoading, setMapLoading] = useState(true);
  const [selectedColumns, setSelectedColumns] = useSessionStorageState<
    string[]
  >("reports.selectedColumns", DEFAULT_COLUMN_KEYS);
  const [search, setSearch] = useSessionStorageState("reports.search", "");
  const [loggedInOnly, setLoggedInOnly] = useSessionStorageState(
    "reports.loggedInOnly",
    false,
  );
  const [pageSize, setPageSize] = useSessionStorageState(
    "reports.pageSize",
    25,
  );

  // ---- server-side business filters: signup window + country ----
  const [signupWindow, setSignupWindow] =
    useSessionStorageState<SignupWindowState>(
      "reports.window",
      DEFAULT_SIGNUP_WINDOW,
    );
  const [countrySel, setCountrySel] = useSessionStorageState<string[]>(
    "reports.country",
    [],
  );
  const windowRange = useMemo(
    () => resolveSignupWindow(signupWindow),
    [signupWindow],
  );

  const [countryOptions, setCountryOptions] = useState<string[]>([]);
  const [nationalityOptions, setNationalityOptions] = useState<string[]>([]);
  /*
   * Account type is a super-admin control.
   *
   * Everyone else reports on guest-membership members only — that is the promo
   * population, since anyone on another membership has been upgraded out of it. Core
   * enforces the same rule on both the report and the filter options; hiding the
   * picker here is the affordance, not the permission.
   */
  const { isSuperAdmin } = useRoleAccess();
  const canChooseAccountType = isSuperAdmin();

  const [accountTypeOptions, setAccountTypeOptions] = useState<string[]>([]);

  // Fetch all countries from the DB `countries` table once on mount.
  // This drives the CountryMultiSelect options independently of report 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;
    };
  }, []);

  // ---- client-side refine filters (persisted across navigation) ----
  const [statusFilter, setStatusFilter] = useSessionStorageState<string | null>(
    "reports.status",
    "all",
  );
  const [nationalityFilter, setNationalityFilter] = useSessionStorageState<
    string[]
  >("reports.nationality", []);
  const [accountTypeFilter, setAccountTypeFilter] = useSessionStorageState<
    string[]
  >("reports.accountType", []);
  /**
   * What to send as the account-type filter.
   *
   * Nothing, when the user may not choose one — core then applies the guest-membership
   * default itself. Sending the stored value instead would be actively harmful: it
   * survives in session storage from a previous visit or an earlier role, and core
   * intersects it with what the user is allowed, so a stale value produces an empty
   * intersection and a report with no rows and no explanation.
   */
  const accountTypeForQuery = canChooseAccountType
    ? accountTypeFilter.length
      ? accountTypeFilter
      : undefined
    : undefined;
  const [dobRange, setDobRange] = useState<[Date | null, Date | null]>([
    null,
    null,
  ]);
  const [sortBy, setSortBy] = useSessionStorageState<string | null>(
    "reports.sort",
    "commence-desc",
  );

  // Inline filters apply live, so the controls bind straight to committed state.
  const [filtersOpen, setFiltersOpen] = useState(false);
  const { series: seriesPalette } = usePromoTheme();

  /* One chip per applied filter, so a narrowed report is never mistaken for the
   * full dataset once the drawer is closed. */
  const filterChips = useMemo<FilterChip[]>(() => {
    const chips: FilterChip[] = [];

    /*
     * The role-fixed account type, shown first and without a remove button.
     *
     * It was previously left out of the chips entirely on the grounds that a chip
     * should only show a filter the reader can clear. That was the wrong trade: the
     * restriction was then invisible on the page, so the report looked like it covered
     * every account type until you opened the drawer. A chip that cannot be removed
     * still does the job these chips exist for — saying the dataset is narrowed.
     */
    if (!canChooseAccountType) {
      chips.push({
        key: "account-type-fixed",
        label: `Account type: ${DEFAULT_ACCOUNT_TYPE}`,
        hint: "Fixed for your role — upgraded contracts are excluded",
      });
    }

    if (codeType !== "both") {
      chips.push({
        key: "code-type",
        label:
          codeType === "promo"
            ? "Karma Subito promo codes"
            : "Member referrals",
        onRemove: () => setCodeType("both"),
      });
    }

    if (isWindowActive(signupWindow)) {
      chips.push({
        key: "window",
        label: "Signup window",
        onRemove: () => setSignupWindow(DEFAULT_SIGNUP_WINDOW),
      });
    }
    if (statusFilter && statusFilter !== "all") {
      chips.push({
        key: "status",
        label: `Status: ${statusFilter === "active" ? "Active" : "Non-active"}`,
        onRemove: () => setStatusFilter("all"),
      });
    }
    const multi: Array<[string, string[], (next: string[]) => void]> = [
      ["Country", countrySel, setCountrySel],
      ["Nationality", nationalityFilter, setNationalityFilter],
      // Only a filter the user actually controls belongs in the chips; the enforced
      // default is stated in the drawer instead.
      ...(canChooseAccountType
        ? [
            ["Account type", accountTypeFilter, setAccountTypeFilter] as [
              string,
              string[],
              (next: string[]) => void,
            ],
          ]
        : []),
    ];
    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 (dobRange[0] || dobRange[1]) {
      chips.push({
        key: "dob",
        label: "Date of birth",
        onRemove: () => setDobRange([null, null]),
      });
    }
    return chips;
  }, [
    codeType,
    setCodeType,
    canChooseAccountType,
    signupWindow,
    statusFilter,
    countrySel,
    nationalityFilter,
    accountTypeFilter,
    dobRange,
  ]);

  const resetFilters = () => {
    setSignupWindow(DEFAULT_SIGNUP_WINDOW);
    setCountrySel([]);
    setStatusFilter("all");
    setNationalityFilter([]);
    setAccountTypeFilter([]);
    setDobRange([null, null]);
  };

  // ---- preview data (not persisted) ----
  const [data, setData] = useState<PromoCodeUsersResult | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [page, setPage] = useState(1);

  const [grandTotal, setGrandTotal] = useState<number | null>(null);
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await getReportUsers([], { page: 1, pageSize: 1 });
        if (cancelled) return;
        if (res.success) setGrandTotal(res.data.pagination.total);
      } catch {
        // best-effort; badge falls back to a skeleton until this resolves
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  // Debounced server-side promo-code search (empty query loads an initial page).
  useEffect(() => {
    let cancelled = false;
    setCodesLoading(true);
    const timer = setTimeout(async () => {
      try {
        const res = await listPromoCodes({
          includeExpired: "true",
          searchTerm: codeSearch.trim() || undefined,
          limit: 100,
          offset: 0,
        });
        if (!res.success) {
          throw new Error(res.message || "Failed to load promo codes");
        }
        const inner = res.data;
        const items: PromoCode[] = Array.isArray(inner)
          ? inner
          : Array.isArray(inner?.data)
            ? inner.data
            : Array.isArray((inner as any)?.items)
              ? (inner as any).items
              : [];
        const seen = new Set<string>();
        const options: { value: string; label: string }[] = [];
        const labels: Record<string, string> = {};
        for (const p of items) {
          const code = codeOf(p);
          if (!code || seen.has(code.toUpperCase())) continue;
          seen.add(code.toUpperCase());
          const name = String(p.name ?? "").trim();
          const label = name && name !== code ? `${name} (${code})` : code;
          options.push({ value: code, label });
          labels[code] = label;
        }
        if (cancelled) return;
        options.sort((a, b) => a.label.localeCompare(b.label));
        setCodeOptions(options);
        setLabelByValue((prev) => ({ ...prev, ...labels }));
        setCodesError(null);
      } catch (e) {
        if (cancelled) return;
        setCodesError(
          e instanceof Error ? e.message : "Failed to load promo codes",
        );
      } finally {
        if (!cancelled) setCodesLoading(false);
      }
    }, 300);
    return () => {
      cancelled = true;
      clearTimeout(timer);
    };
  }, [codeSearch]);

  // Picker options: search results plus any already-selected codes.
  const pickerData = useMemo(() => {
    const byValue = new Map<string, string>();
    for (const o of codeOptions) byValue.set(o.value, o.label);
    for (const v of selectedCodes) {
      if (!byValue.has(v)) byValue.set(v, labelByValue[v] ?? v);
    }
    return Array.from(byValue, ([value, label]) => ({ value, label }));
  }, [codeOptions, selectedCodes, labelByValue]);

  useEffect(() => {
    let cancelled = false;
    void (async () => {
      setMapLoading(true);
      try {
        const res = await getCodeCampaignMap();
        if (!cancelled && res.success) setMapRows(res.data);
      } finally {
        if (!cancelled) setMapLoading(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  /**
   * The codes belonging to each type.
   *
   * Fetched once and paged to exhaustion, because this has to be the *complete* list:
   * it is used as the report's code filter, and a truncated list would silently narrow
   * the report rather than fail. Both flags are asked for in one pass so the two
   * populations come from the same snapshot.
   */
  const [codesByType, setCodesByType] = useState<{
    promo: string[];
    referral: string[];
  } | null>(null);

  useEffect(() => {
    let cancelled = false;
    void (async () => {
      const collect = async (
        flag: "onlyPromoCodes" | "onlyMemberReferralCodes",
      ) => {
        const out: string[] = [];
        const pageSize = 250;
        for (let offset = 0; offset < 10000; offset += pageSize) {
          const res = await listPromoCodes({
            [flag]: "true",
            limit: pageSize,
            offset,
          } as never);
          if (!res.success) break;
          const items = res.data?.data ?? [];
          for (const item of items) {
            const code = String(
              (item as { code?: string; name?: string }).code ??
                (item as { name?: string }).name ??
                "",
            ).trim();
            if (code) out.push(code);
          }
          if (items.length < pageSize) break;
        }
        return out;
      };

      const [promo, referral] = await Promise.all([
        collect("onlyPromoCodes"),
        collect("onlyMemberReferralCodes"),
      ]);
      if (!cancelled) setCodesByType({ promo, referral });
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  // Campaign picker options + both directions of the code↔campaign mapping.
  const campaignOptions = useMemo(() => {
    const byId = new Map<string, string>();
    for (const r of mapRows) byId.set(r.campaign_id, r.campaign_name);
    return Array.from(byId, ([value, label]) => ({ value, label })).sort(
      (a, b) => a.label.localeCompare(b.label),
    );
  }, [mapRows]);

  const campaignIdToCodes = useMemo(() => {
    const m = new Map<string, string[]>();
    for (const r of mapRows) {
      const list = m.get(r.campaign_id) ?? [];
      list.push(r.promo_code_name);
      m.set(r.campaign_id, list);
    }
    return m;
  }, [mapRows]);

  const codeToCampaigns = useMemo(() => {
    const m = new Map<string, string[]>();
    for (const r of mapRows) {
      const key = r.promo_code_name.toUpperCase();
      const list = m.get(key) ?? [];
      if (!list.includes(r.campaign_name)) list.push(r.campaign_name);
      m.set(key, list);
    }
    return m;
  }, [mapRows]);

  // Promo codes from the selected campaigns, unioned (case-insensitively) with
  // the manually-picked codes. Empty ⇒ the API returns all members.
  const effectiveCodes = useMemo(() => {
    const byUpper = new Map<string, string>();
    for (const c of selectedCodes) byUpper.set(c.toUpperCase(), c);
    for (const id of selectedCampaigns) {
      for (const c of campaignIdToCodes.get(id) ?? []) {
        if (!byUpper.has(c.toUpperCase())) byUpper.set(c.toUpperCase(), c);
      }
    }
    const picked = Array.from(byUpper.values());

    /*
     * The type acts as a boundary on the codes, not as a separate filter.
     *
     * An empty code list means "every promo-code signup" to the API, so a type on its
     * own has to *supply* the codes. When codes were also picked by hand or by campaign
     * the two are intersected — asking for member referrals while a marketing code is
     * selected means neither, not both.
     */
    if (codeType === "both") return picked;
    // Until the lists arrive, sending the picked codes unnarrowed would briefly report
    // the wrong population; an unmatched sentinel reports nothing instead.
    if (!codesByType) return picked.length > 0 ? picked : ["\u0000"];

    const allowed = new Set(
      (codeType === "promo" ? codesByType.promo : codesByType.referral).map(
        (c) => c.trim().toUpperCase(),
      ),
    );
    if (picked.length === 0) {
      return (
        codeType === "promo" ? codesByType.promo : codesByType.referral
      ).slice();
    }
    const narrowed = picked.filter((c) => allowed.has(c.trim().toUpperCase()));
    // An empty intersection must report nothing, not everything.
    return narrowed.length > 0 ? narrowed : ["\u0000"];
  }, [
    selectedCodes,
    selectedCampaigns,
    campaignIdToCodes,
    codeType,
    codesByType,
  ]);

  const campaignForCode = (code: unknown) =>
    codeToCampaigns.get(String(code ?? "").toUpperCase())?.join(", ") ?? "";

  const loadPreview = async (targetPage: number) => {
    setLoading(true);
    try {
      // No codes selected ⇒ the API returns all promo-code signups.
      const res = await getReportUsers(effectiveCodes, {
        page: targetPage,
        pageSize,
        search: search.trim() || undefined,
        loggedIn: loggedInOnly || undefined,
        from: windowRange.from,
        to: windowRange.to,
        country: countrySel.length ? countrySel : undefined,
        status:
          statusFilter && statusFilter !== "all"
            ? (statusFilter as "active" | "inactive")
            : undefined,
        nationality: nationalityFilter.length ? nationalityFilter : undefined,
        accountType: accountTypeForQuery,
        dobFrom: toYMD(dobRange[0]),
        dobTo: toYMD(dobRange[1]),
      });
      if (res.success) {
        setData(res.data);
        setError(null);
        setPage(res.data.pagination.page);
      } else {
        setError(res.message || "Failed to load report");
        notifications.show({
          color: "red",
          message: res.message || "Failed to load report",
        });
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : "Failed to load report");
    } finally {
      setLoading(false);
    }
  };

  // Debounced refresh on config change; resets to page 1. Window/country are
  // server-side, so changing them re-queries the whole dataset.
  useEffect(() => {
    const t = setTimeout(() => {
      setPage(1);
      void loadPreview(1);
    }, 350);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    effectiveCodes,
    search,
    loggedInOnly,
    pageSize,
    windowRange.from,
    windowRange.to,
    countrySel,
    statusFilter,
    nationalityFilter,
    accountTypeFilter,
    dobRange,
  ]);

  useEffect(() => {
    let cancelled = false;
    void (async () => {
      const res = await getReportFilterOptions(effectiveCodes, {
        from: windowRange.from,
        to: windowRange.to,
      });
      if (cancelled || !res.success) return;
      // countryOptions are now fetched from the DB at mount — do not overwrite them here.
      setNationalityOptions(res.data.nationalities);
      setAccountTypeOptions(res.data.accountTypes);
    })();
    return () => {
      cancelled = true;
    };
  }, [effectiveCodes, windowRange.from, windowRange.to]);

  const activeColumns = useMemo(
    () => resolveColumns(REPORT_COLUMNS, selectedColumns),
    [selectedColumns],
  );

  const items = data?.items ?? [];

  // The window/country/status/nationality/account-type/DOB filters are all
  // applied server-side, so the loaded page is already the filtered set; here
  // we only sort it (client-side, within the page) and attach the campaign.
  const visibleUsers = useMemo(() => {
    const rows = [...items].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 (sortBy) {
        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;
      }
    });
    return rows.map((u) => ({
      ...u,
      campaign: campaignForCode(u.signup_promo_code),
    }));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [items, sortBy, codeToCampaigns]);

  // Export all pages for the selected codes, using the toggled-on columns.
  const getExportData = async (signal: AbortSignal) => {
    const all: PromoCodeUserRow[] = [];
    const exportPageSize = 200;
    let p = 1;
    let totalPages = 1;
    do {
      if (signal.aborted) {
        throw new DOMException("Export cancelled", "AbortError");
      }
      const res = await withTimeout(
        getReportUsers(effectiveCodes, {
          page: p,
          pageSize: exportPageSize,
          search: search.trim() || undefined,
          loggedIn: loggedInOnly || undefined,
          from: windowRange.from,
          to: windowRange.to,
          country: countrySel.length ? countrySel : undefined,
          status:
            statusFilter && statusFilter !== "all"
              ? (statusFilter as "active" | "inactive")
              : undefined,
          nationality: nationalityFilter.length ? nationalityFilter : undefined,
          accountType: accountTypeForQuery,
          dobFrom: toYMD(dobRange[0]),
          dobTo: toYMD(dobRange[1]),
        }),
      );
      if (!res.success) {
        throw new Error(res.message || "Failed to load report");
      }
      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);
      p += 1;
    } while (p <= totalPages);

    const enriched = all.map((u) => ({
      ...u,
      campaign: campaignForCode(u.signup_promo_code),
    }));
    return buildExportSheet("Report", activeColumns, enriched);
  };

  const total = data?.pagination.total ?? 0;
  const totalPages = data?.pagination.totalPages ?? 1;
  const hasConfig = activeColumns.length > 0;

  /*
   * Headline figures for the report.
   *
   * `grandTotal` is the dataset-wide count from the server; `total` is what the
   * current filters match. Showing both makes it obvious when a report has been
   * narrowed — the two figures diverging is the signal.
   */
  const statTiles = useMemo<StatTileSpec[]>(() => {
    const paint = seriesPalette(4);
    const tiles: StatTileSpec[] = [
      {
        key: "matched",
        label: "Members matched",
        value: total,
        icon: <IconUsers size={17} />,
        color: paint[0].from,
        colorTo: paint[0].to,
        hint:
          grandTotal !== null && grandTotal !== total
            ? `of ${grandTotal.toLocaleString()} total`
            : "Current filters",
        percent:
          grandTotal !== null && grandTotal > 0
            ? (total / grandTotal) * 100
            : undefined,
      },
      {
        key: "columns",
        label: "Columns",
        value: activeColumns.length,
        icon: <IconCalendarStats size={17} />,
        color: paint[1].from,
        colorTo: paint[1].to,
        hint: hasConfig ? "Included in export" : "Pick at least one",
      },
      {
        // Deliberately not a "filters applied" count — the chips directly above
        // already show that. The report's scope (which codes/campaigns were
        // picked in setup) is the thing not visible anywhere else.
        key: "scope",
        label: "Codes in scope",
        value: selectedCodes.length,
        icon: <IconWorld size={17} />,
        color: paint[2].from,
        colorTo: paint[2].to,
        hint:
          selectedCodes.length === 0 && selectedCampaigns.length === 0
            ? "All promo codes"
            : selectedCampaigns.length > 0
              ? `plus ${selectedCampaigns.length} campaign${selectedCampaigns.length === 1 ? "" : "s"}`
              : "Picked in setup",
      },
    ];
    // Only shown when the toggle is on: a "0" here would read as a count of
    // logged-in members rather than as "the restriction is off".
    if (loggedInOnly) {
      tiles.push({
        key: "logged-in-only",
        label: "Logged in only",
        value: total,
        icon: <IconLogin size={17} />,
        color: paint[3].from,
        colorTo: paint[3].to,
        hint: "Restriction active",
      });
    }
    return tiles;
  }, [
    total,
    grandTotal,
    activeColumns.length,
    hasConfig,
    selectedCodes.length,
    selectedCampaigns.length,
    loggedInOnly,
    seriesPalette,
  ]);

  const displayCount = data ? total : null;

  return (
    <Stack gap="lg">
      <Group justify="space-between" align="center">
        <Group gap="sm">
          <Title order={4}>Members report</Title>
          <InfoHint
            width={340}
            ariaLabel="About the members report"
            label="Build a flexible user report: pick one or more promo codes, choose which columns to include, then export."
          />
          {grandTotal !== null && (
            /* No explicit colour — follows the theme accent. */
            <Badge variant="light" size="lg">
              <AnimatedNumber value={grandTotal} /> user
              {grandTotal === 1 ? "" : "s"}
            </Badge>
          )}
        </Group>
        <Group gap="xs" align="center">
          <FilterTrigger
            /*
             * Counts only the filters the reader chose.
             *
             * The role-fixed account type is a chip but not a badge: the badge says
             * "you have narrowed this, and can widen it again". A permanent 1 that no
             * amount of clearing removes would train people to ignore the badge
             * entirely, which is the opposite of what it is for.
             */
            activeCount={filterChips.filter((c) => c.onRemove).length}
            onClick={() => setFiltersOpen(true)}
          />
          <ExportButton
            label="Export Report"
            filename="promo-code-report"
            getData={getExportData}
            section="reports"
          />
        </Group>
      </Group>

      {codesError && (
        <Alert color="red" variant="light">
          {codesError}
        </Alert>
      )}

      <FilterPanel title="Report setup" storageKey="reports.members.setup.open">
        <MultiSelect
          label="Campaigns"
          placeholder={
            selectedCampaigns.length
              ? undefined
              : "All campaigns — pick to filter"
          }
          data={campaignOptions}
          value={selectedCampaigns}
          onChange={setSelectedCampaigns}
          searchable
          clearable
          hidePickedOptions
          rightSection={mapLoading ? <Loader size="xs" /> : undefined}
          nothingFoundMessage={mapLoading ? "Loading…" : "No matching campaign"}
          description="Select campaigns to include their members. Combines with promo codes (no duplicates)."
        />

        {/*
          Which population the report covers.
          
          Placed above the code picker because it bounds it: the codes offered below are
          still every code, but the report is narrowed to the chosen type, and picking a
          code outside it reports nothing rather than widening back.
        */}
        {/*
          A dropdown rather than a segmented control: the labels are long enough that
          three side-by-side segments wrapped and crowded the drawer, and this is a
          one-of-three choice like every other control here.
        */}
        <Select
          label="Report on"
          value={codeType}
          onChange={(v) => v && setCodeType(v as "both" | "promo" | "referral")}
          data={[
            { value: "both", label: "Both" },
            {
              // The code counts sit on the options themselves, so the size of each
              // population is visible while choosing rather than only after.
              value: "promo",
              label: codesByType
                ? `Karma Subito promo codes (${codesByType.promo.length})`
                : "Karma Subito promo codes",
            },
            {
              value: "referral",
              label: codesByType
                ? `Member referrals (${codesByType.referral.length})`
                : "Member referrals",
            },
          ]}
          // A report always covers something, so there is no empty state to offer.
          allowDeselect={false}
          description={
            codeType === "both"
              ? "Marketing codes and member-referral codes together."
              : codeType === "promo"
                ? "Marketing codes only."
                : "Member-referral codes only."
          }
        />

        <MultiSelect
          label="Promo codes"
          placeholder={
            selectedCodes.length
              ? undefined
              : "All members — pick codes to filter"
          }
          data={pickerData}
          value={selectedCodes}
          onChange={setSelectedCodes}
          searchable
          searchValue={codeSearch}
          onSearchChange={setCodeSearch}
          filter={({ options }) => options}
          clearable
          limit={100}
          rightSection={codesLoading ? <Loader size="xs" /> : undefined}
          nothingFoundMessage={
            codesLoading ? "Searching…" : "No matching promo code"
          }
          description="Leave empty to include all members; select one or more codes to filter."
        />

        <ColumnArranger
          columns={REPORT_COLUMNS}
          selected={selectedColumns}
          onChange={setSelectedColumns}
        />

        <Switch
          label="Logged-in users only"
          checked={loggedInOnly}
          onChange={(e) => setLoggedInOnly(e.currentTarget.checked)}
        />
      </FilterPanel>

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

      <PageSection>
        <StatTiles tiles={statTiles} loading={loading && !data} />
      </PageSection>

      <FilterDrawer
        opened={filtersOpen}
        onClose={() => setFiltersOpen(false)}
        // Same count as the trigger — a fixed filter is not one the drawer's
        // reset can clear, so counting it here would promise otherwise.
        activeCount={filterChips.filter((c) => c.onRemove).length}
        onReset={resetFilters}
      >
        <FilterSection
          icon={<IconCalendarStats size={15} />}
          title="Acquisition window"
          description="Applies to the whole dataset, not just this page."
        >
          {/*
           * Labelled "Date of Acquisition" because that is exactly what it
           * filters. The window applies to members.created_at, which is the same
           * column this report displays as Date of Acquisition — there is no
           * separate acquisition/commencement column on `members`. The old
           * default label ("Signups in") named the same field differently, which
           * read as though the filter and the column were unrelated.
           */}
          <SignupWindowControl
            value={signupWindow}
            onChange={setSignupWindow}
            label="Date of Acquisition"
          />
        </FilterSection>

        <FilterSection
          icon={<IconWorld size={15} />}
          title="Member"
          description="Country, status, nationality and account type."
        >
          <CountryMultiSelect
            value={countrySel}
            onChange={setCountrySel}
            options={countryOptions}
          />
          <Select
            label="Status"
            value={statusFilter ?? "all"}
            onChange={(v) => setStatusFilter(v ?? "all")}
            allowDeselect={false}
            data={[
              { value: "all", label: "All" },
              { value: "active", label: "Active" },
              { value: "inactive", label: "Non-active" },
            ]}
          />
          <SelectAllMultiSelect
            label="Nationality"
            placeholder="Any nationality"
            value={nationalityFilter}
            onChange={setNationalityFilter}
            options={nationalityOptions}
          />
          {canChooseAccountType ? (
            <MultiSelect
              label="Account type"
              placeholder={
                accountTypeFilter.length ? undefined : "Any account type"
              }
              value={accountTypeFilter}
              onChange={setAccountTypeFilter}
              data={accountTypeOptions.map((c) => ({ value: c, label: c }))}
              clearable
              searchable
              hidePickedOptions
            />
          ) : (
            /*
             * The same control, shown with its value and disabled.
             *
             * Not hidden, and not an empty picker: the filter *is* applied, so it
             * belongs on screen where every other applied filter is. Disabled rather
             * than merely un-clearable because Mantine's `clearable` and per-pill
             * remove buttons would otherwise let it be emptied — and an emptied
             * picker would look like "any account type" while core still scoped the
             * report to one.
             *
             * The description explains the consequence, since a locked filter with no
             * reason reads as a bug.
             */
            <MultiSelect
              label="Account type"
              value={[DEFAULT_ACCOUNT_TYPE]}
              data={[
                { value: DEFAULT_ACCOUNT_TYPE, label: DEFAULT_ACCOUNT_TYPE },
              ]}
              description="Fixed for your role. Members whose contract has been upgraded to another membership are not included."
              disabled
              readOnly
            />
          )}
        </FilterSection>

        <FilterSection
          icon={<IconCake size={15} />}
          title="Date of birth"
          description="Bounds on the member's date of birth."
          withDivider={false}
        >
          <Stack gap={4}>
            <Text size="sm" fw={500}>
              Date of birth
            </Text>
            {/* Capped at today: a date of birth in the future cannot match a
                real member, so the calendar shouldn't offer one. The two ends
                also bound each other so the range can't be inverted. */}
            <Group gap="xs" grow>
              <DatePickerInput
                placeholder="From"
                maxDate={dobRange[1] ? new Date(dobRange[1]) : new Date()}
                value={dobRange[0] ? new Date(dobRange[0]) : null}
                onChange={(val) =>
                  setDobRange(([_, end]) => [val as Date | null, end])
                }
                clearable
                valueFormat="DD MMM YYYY"
              />
              <DatePickerInput
                placeholder="To"
                minDate={dobRange[0] ? new Date(dobRange[0]) : undefined}
                maxDate={new Date()}
                value={dobRange[1] ? new Date(dobRange[1]) : null}
                onChange={(val) =>
                  setDobRange(([start]) => [start, val as Date | null])
                }
                clearable
                valueFormat="DD MMM YYYY"
              />
            </Group>
          </Stack>
        </FilterSection>
      </FilterDrawer>

      <Card className={promoStyles.sectionCard} p="lg">
        <Group gap="sm" align="center" mb="sm">
          <Text fw={700} size="lg">
            Members {displayCount !== null ? `(${displayCount})` : ""}
          </Text>
          {loading && <Loader size="xs" />}
        </Group>
        <Group
          gap="sm"
          align="center"
          justify="space-between"
          wrap="wrap"
          mb="md"
        >
          <TextInput
            placeholder="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={sortBy ?? "commence-desc"}
            onChange={(v) => setSortBy(v ?? "commence-desc")}
            allowDeselect={false}
            data={[
              { 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)" },
            ]}
          />
        </Group>

        {error && (
          <Alert color="red" variant="light" mb="md">
            {error}
          </Alert>
        )}

        {!hasConfig ? (
          <Text c="dimmed" ta="center" py="xl">
            Select at least one column to preview the report.
          </Text>
        ) : (
          <ReportPreview
            activeColumns={activeColumns}
            rows={visibleUsers}
            loading={loading}
            rowKey={(u, idx) => String(u.membership_number ?? idx)}
            onRowClick={(u) => openMember(u)}
            emptyMessage="No members match the current filters."
            total={total}
            page={page}
            totalPages={totalPages}
            pageSize={pageSize}
            onPageChange={(p) => {
              setPage(p);
              void loadPreview(p);
            }}
            onPageSizeChange={setPageSize}
            pageSizeOptions={PAGE_SIZE_OPTIONS}
            showFooter={Boolean(data)}
          />
        )}
      </Card>
    </Stack>
  );
}
