"use client";

/**
 * Promo-code bookings.
 *
 * Everything here is driven by two server-side endpoints — one for the chart
 * aggregates, one for the paginated rows. The charts deliberately do NOT derive
 * from the loaded table page: that was the bug on the promo-code detail page,
 * where charts described 100 rows while the tiles described thousands.
 *
 * "Internal" and "curated" mean exactly what the campaign dashboard's Bookings
 * Analytics means — the same SQL predicates — so figures reconcile across pages.
 */

import {
  Badge,
  Box,
  Card,
  Container,
  Group,
  MultiSelect,
  Pagination,
  SegmentedControl,
  Skeleton,
  Stack,
  Table,
  Text,
  Title,
} from "@mantine/core";
import {
  IconBuildingCommunity,
  IconCalendarStats,
  IconFilter,
  IconUsers,
  IconWorld,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useSessionStorageState } from "@/hooks/useSessionStorageState";
import {
  DEFAULT_SIGNUP_WINDOW,
  isWindowActive,
  resolveSignupWindow,
  SignupWindowControl,
  type SignupWindowState,
  type WindowPreset,
} from "@/routes/reports/_shared";
import {
  getPromoCodeBookingAnalytics,
  listPromoCodeBookings,
  type PromoCodeBooking,
  type PromoCodeBookingAnalytics,
} from "@/lib/features/promo-code-campaigns/query";
import type { AccessScope } from "@/lib/features/types";
import { ExportButton } from "@/lib/export/ExportButton";
import { useRoleAccess } from "@/hooks/useRoleAccess";
import { ViewpointBookingsPanel } from "./_components/ViewpointBookingsPanel";
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 { AnimatedDonut } from "@/routes/promo-code-campaigns/_components/charts/AnimatedDonut";
import { CountryCampaignBars } from "@/routes/promo-code-campaigns/_components/charts/CountryCampaignBars";
import { CountryCampaignLines } from "@/routes/promo-code-campaigns/_components/charts/CountryCampaignLines";
import { areaOf } from "@/routes/promo-code-campaigns/_components/regions";
import styles from "@/routes/promo-code-campaigns/_components/promo.module.css";

const PAGE_SIZE = 25;

/** Presets accepted from the URL — anything else is ignored rather than trusted. */
const WINDOW_PRESETS = new Set<WindowPreset>([
  "all",
  "today",
  "7d",
  "30d",
  "month",
  "custom",
]);

/** Statuses that read as in-flight rather than confirmed. */
const WARN_STATUSES = new Set(["HOLD", "SYSTEM_PROCESSING"]);

/** Statuses meaning the booking did not happen. */
const DEAD_STATUSES = new Set([
  "SELF_CANCELLED",
  "SYSTEM_CANCELLED",
  "FAILED",
  "SYSTEM_FAULT",
]);

/*
 * Human labels for booking_status.
 *
 * The raw enum names don't map onto the words people use — "complete" is
 * CHECKED_OUT or ENDED, and "active" is CHECKED_IN — so a prettified enum name
 * would still not be findable in the picker.
 */
const STATUS_LABELS: Record<string, string> = {
  HOLD: "Hold",
  SYSTEM_PROCESSING: "Processing",
  UPCOMING: "Upcoming",
  CHECKED_IN: "Active (checked in)",
  CHECKED_OUT: "Complete (checked out)",
  ENDED: "Complete (ended)",
  SELF_CANCELLED: "Cancelled by member",
  SYSTEM_CANCELLED: "Cancelled by system",
  FAILED: "Failed",
  SYSTEM_FAULT: "System fault",
};

function statusLabel(status: string): string {
  return STATUS_LABELS[status] ?? status.replace(/_/g, " ").toLowerCase();
}

/** Which timeline the line chart plots. */
type Timing = "booked" | "stay";

function formatDate(value: string | null): string {
  if (!value) return "—";
  const d = new Date(value);
  return Number.isFinite(d.getTime()) ? d.toLocaleDateString() : "—";
}

/**
 * Which booking source the page is reading.
 *
 * `core` is bookings created through core — complete for anything this console
 * created, and nothing else. `viewpoint` is the manually imported Viewpoint reports,
 * which cover bookings core never saw. Neither is a superset of the other, so the
 * page offers both rather than picking one and calling it "bookings".
 */
type BookingSourceView = "core" | "viewpoint";

/**
 * Whether the Core bookings view is offered.
 *
 * Turned off at the team's request: the Viewpoint import is the fuller picture, and
 * showing two booking views invited the question of which one was authoritative.
 *
 * A flag rather than deleted code. The core view is a working, promo-scoped booking
 * list with its own filters, charts and export — worth keeping intact — and this is the
 * whole switch: set it back to `true` and the toggle and every core section return
 * exactly as they were. Nothing else needs touching.
 */
const SHOW_CORE_VIEW = false;

export default function PromoCodeBookingsClientPage({
  accessScope,
}: {
  accessScope: AccessScope;
}) {
  const { isSuperAdmin } = useRoleAccess();

  /*
   * Which source the page shows.
   *
   * Always Viewpoint while the core view is off, and nothing links here asking for
   * anything else — the dashboard's booking figures now come from this same data, so a
   * drill-down has one destination and one set of numbers.
   */
  const [sourceView, setSourceView] = useState<BookingSourceView>(
    SHOW_CORE_VIEW ? "core" : "viewpoint",
  )

  const [signupWindow, setSignupWindow] =
    useSessionStorageState<SignupWindowState>(
      "promoBookings.window",
      DEFAULT_SIGNUP_WINDOW,
    );
  const [codeSel, setCodeSel] = useSessionStorageState<string[]>(
    "promoBookings.codes",
    [],
  );
  const [kind, setKind] = useSessionStorageState<"internal" | "curated">(
    "promoBookings.kind",
    "internal",
  );
  const [statusSel, setStatusSel] = useSessionStorageState<string[]>(
    "promoBookings.statuses",
    [],
  );
  const [entitySel, setEntitySel] = useSessionStorageState<string[]>(
    "promoBookings.entities",
    [],
  );
  /*
   * Which promo codes are in scope when none are explicitly selected.
   *
   * Defaults to campaign-attached codes because this page is only reachable from
   * the campaigns dashboard, and matching its scope is what makes the totals
   * agree. "all" additionally covers codes with no campaign.
   */
  const [scope, setScope] = useSessionStorageState<"campaigns" | "all">(
    "promoBookings.scope",
    "campaigns",
  );
  const [timing, setTiming] = useState<Timing>("booked");
  const [filtersOpen, setFiltersOpen] = useState(false);

  /*
   * Arrive in the scope the caller was looking at.
   *
   * This page has no nav entry — it is opened from the campaign dashboard's
   * Bookings Analytics badges, which pass the kind they were clicked on plus the
   * window and (only when narrowing) the codes. Applied once on mount rather than
   * on every render, so the user can then change any filter without the URL
   * snapping it back.
   */
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const [seeded, setSeeded] = useState(false);
  useEffect(() => {
    if (seeded) return;
    const urlKind = searchParams.get("kind");
    if (urlKind === "internal" || urlKind === "curated") setKind(urlKind);
    const urlScope = searchParams.get("scope");
    if (urlScope === "campaigns" || urlScope === "all") setScope(urlScope);
    const urlCodes = searchParams.get("codes");
    if (urlCodes) {
      setCodeSel(
        urlCodes
          .split(",")
          .map((c) => c.trim())
          .filter(Boolean),
      );
    }
    // The preset is carried verbatim so the window resolves identically here;
    // see the note in the dashboard's openBookingsPage.
    const preset = searchParams.get("preset") as WindowPreset | null;
    if (preset && WINDOW_PRESETS.has(preset)) {
      setSignupWindow({
        preset,
        customFrom: searchParams.get("cfrom"),
        customTo: searchParams.get("cto"),
      });
    }
    setSeeded(true);
  }, [seeded, searchParams, setKind, setCodeSel, setSignupWindow, setScope]);

  const [codeOptions, setCodeOptions] = useState<string[]>([]);
  const [statusOptions, setStatusOptions] = useState<string[]>([]);
  const [entityOptions, setEntityOptions] = useState<string[]>([]);
  const [analytics, setAnalytics] = useState<PromoCodeBookingAnalytics | null>(
    null,
  );
  const [analyticsLoading, setAnalyticsLoading] = useState(true);
  const [rows, setRows] = useState<PromoCodeBooking[]>([]);
  const [rowsTotal, setRowsTotal] = useState(0);
  const [rowsLoading, setRowsLoading] = useState(true);
  const [page, setPage] = useState(1);
  const [error, setError] = useState<string | null>(null);

  const windowRange = useMemo(
    () => resolveSignupWindow(signupWindow),
    [signupWindow],
  );
  const { series: seriesPalette, roles } = usePromoTheme();

  // Aggregates: every booking in scope, independent of the table's page.
  useEffect(() => {
    let cancelled = false;
    setAnalyticsLoading(true);
    void getPromoCodeBookingAnalytics({
      codes: codeSel,
      from: windowRange.from,
      to: windowRange.to,
      statuses: statusSel,
      entities: entitySel,
      scope,
    })
      .then((res) => {
        if (cancelled) return;
        if (res.success && res.data) {
          setAnalytics(res.data);
          /*
           * Filter options come from the aggregate's own byPromoCode, unioned
           * across responses.
           *
           * Not from /all-known-promo-codes: that endpoint is gated on
           * promocode-access-list:read, which a campaigns-only user may not
           * have, so the picker would have silently 403'd and stayed empty. This
           * also lists only codes that actually have bookings.
           *
           * Unioned rather than replaced because a filtered response returns only
           * the selected codes — replacing would strand the user unable to widen
           * their own selection.
           */
          const union = (
            prev: string[],
            incoming: Array<string | null | undefined>,
          ) => {
            const merged = new Set(prev);
            for (const v of incoming) if (v) merged.add(v);
            return Array.from(merged).sort();
          };
          setCodeOptions((prev) =>
            union(
              prev,
              res.data!.byPromoCode.map((r) => r.promo_code),
            ),
          );
          /*
           * The full enum, not just the statuses present in the current result.
           *
           * Cancelled bookings are excluded by default, so `byStatus` never
           * contains them — sourcing options from it alone meant the statuses a
           * user most wants to look for were the ones they could not pick.
           */
          setStatusOptions((prev) =>
            union(prev, [
              ...(res.data!.allStatuses ?? []),
              ...res.data!.byStatus.map((r) => r.status),
            ]),
          );
          setEntityOptions((prev) =>
            union(
              prev,
              res.data!.byEntity.map((r) => r.name),
            ),
          );
        } else {
          setError(res.message || "Failed to load booking analytics");
        }
      })
      .finally(() => {
        if (!cancelled) setAnalyticsLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [codeSel, statusSel, entitySel, scope, windowRange.from, windowRange.to]);

  // Any filter change invalidates the page number, so reset before refetching.
  useEffect(() => {
    setPage(1);
  }, [
    codeSel,
    kind,
    statusSel,
    entitySel,
    scope,
    windowRange.from,
    windowRange.to,
  ]);

  useEffect(() => {
    let cancelled = false;
    setRowsLoading(true);
    void listPromoCodeBookings({
      codes: codeSel,
      kind,
      from: windowRange.from,
      to: windowRange.to,
      statuses: statusSel,
      entities: entitySel,
      scope,
      page,
      pageSize: PAGE_SIZE,
    })
      .then((res) => {
        if (cancelled) return;
        if (res.success && res.data) {
          setRows(res.data.items);
          setRowsTotal(res.data.pagination.total);
        } else {
          setRows([]);
          setRowsTotal(0);
        }
      })
      .finally(() => {
        if (!cancelled) setRowsLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [
    codeSel,
    kind,
    statusSel,
    entitySel,
    scope,
    windowRange.from,
    windowRange.to,
    page,
  ]);

  const totals = analytics?.totals;

  const statTiles = useMemo<StatTileSpec[]>(() => {
    const paint = seriesPalette(4);
    const internal = totals?.internal ?? 0;
    const curated = totals?.curated ?? 0;
    const all = internal + curated;
    return [
      {
        key: "internal",
        label: "Karma Subito bookings",
        value: internal,
        icon: <IconBuildingCommunity size={17} />,
        color: roles.internal.from,
        colorTo: roles.internal.to,
        hint: "Stays at Karma properties",
        percent: all > 0 ? (internal / all) * 100 : 0,
      },
      {
        key: "curated",
        label: "Curated bookings",
        value: curated,
        icon: <IconCalendarStats size={17} />,
        color: roles.external.from,
        colorTo: roles.external.to,
        hint: "Curated events",
        percent: all > 0 ? (curated / all) * 100 : 0,
      },
      {
        key: "members",
        label: "Members booking",
        value: totals?.members ?? 0,
        icon: <IconUsers size={17} />,
        color: paint[2].from,
        colorTo: paint[2].to,
        hint: "Distinct members with a booking",
      },
      {
        key: "entities",
        label: "Properties / events",
        value: totals?.entities ?? 0,
        icon: <IconWorld size={17} />,
        color: paint[3].from,
        colorTo: paint[3].to,
        hint: "Distinct destinations booked",
      },
    ];
  }, [totals, seriesPalette, roles]);

  /*
   * Both booking kinds as series, so a stacked chart shows the split rather than
   * one number. Row values are keyed by series *name*, which is what the visx
   * charts read.
   */
  const bookingSeries = useMemo(
    () => [
      {
        key: "internal",
        /*
         * `name` is also the accessor: CountryCampaignBars reads each row by series
         * name, so this string and the data keys below have to be renamed together —
         * changing one alone leaves the chart silently drawing zeros.
         */
        name: "Karma Subito",
        color: roles.internal.from,
        colorTo: roles.internal.to,
      },
      {
        key: "curated",
        name: "Curated",
        color: roles.external.from,
        colorTo: roles.external.to,
      },
    ],
    [roles],
  );

  const timingRows = useMemo(() => {
    if (!analytics) return [];
    if (timing === "booked") {
      return analytics.byBookedDay.map((r) => ({
        label: new Date(`${r.day}T00:00:00Z`).toLocaleDateString(undefined, {
          day: "2-digit",
          month: "short",
        }),
        "Karma Subito": Number(r.internal || 0),
        Curated: Number(r.curated || 0),
      }));
    }
    return analytics.byStayMonth.map((r) => ({
      label: new Date(`${r.month}-01T00:00:00Z`).toLocaleDateString(undefined, {
        month: "short",
        year: "2-digit",
      }),
      "Karma Subito": Number(r.internal || 0),
      Curated: Number(r.curated || 0),
    }));
  }, [analytics, timing]);

  /*
   * Country totals folded into areas using the console's own mapping, so this
   * page and the campaign dashboard bucket identically. Done here rather than in
   * SQL to keep one definition of the mapping.
   */
  const areaRows = useMemo(() => {
    if (!analytics) return [];
    const byArea = new Map<
      string,
      { "Karma Subito": number; Curated: number }
    >();
    for (const row of analytics.byCountry) {
      const area = areaOf(row.country);
      const label = area === "EU_UK_ROW" ? "EU / UK / ROW" : area;
      const cur = byArea.get(label) ?? { "Karma Subito": 0, Curated: 0 };
      cur["Karma Subito"] += Number(row.internal || 0);
      cur.Curated += Number(row.curated || 0);
      byArea.set(label, cur);
    }
    return Array.from(byArea, ([label, v]) => ({ label, ...v })).sort(
      (a, b) => b["Karma Subito"] + b.Curated - (a["Karma Subito"] + a.Curated),
    );
  }, [analytics]);

  const entityRows = useMemo(
    () =>
      (analytics?.byEntity ?? [])
        .filter((r) => r.name)
        .map((r) => ({
          label: String(r.name),
          "Karma Subito": Number(r.internal || 0),
          Curated: Number(r.curated || 0),
        })),
    [analytics],
  );

  const statusSlices = useMemo(() => {
    const rowsIn = analytics?.byStatus ?? [];
    const palette = seriesPalette(Math.max(1, rowsIn.length));
    return rowsIn
      .filter((r) => r.status)
      .map((r, i) => ({
        name: String(r.status).replace(/_/g, " ").toLowerCase(),
        value: Number(r.count || 0),
        color: palette[i]?.from ?? palette[0].from,
        colorTo: palette[i]?.to ?? palette[0].to,
      }));
  }, [analytics, seriesPalette]);

  const filterChips = useMemo<FilterChip[]>(() => {
    const chips: FilterChip[] = [];
    if (isWindowActive(signupWindow)) {
      chips.push({
        key: "window",
        label: "Signup window",
        onRemove: () => setSignupWindow(DEFAULT_SIGNUP_WINDOW),
      });
    }
    for (const code of codeSel) {
      chips.push({
        key: `code:${code}`,
        label: `Code: ${code}`,
        onRemove: () => setCodeSel(codeSel.filter((c) => c !== code)),
      });
    }
    if (scope === "all") {
      chips.push({
        key: "scope",
        label: "Incl. codes with no campaign",
        onRemove: () => setScope("campaigns"),
      });
    }
    for (const st of statusSel) {
      chips.push({
        key: `status:${st}`,
        label: `Status: ${statusLabel(st)}`,
        onRemove: () => setStatusSel(statusSel.filter((v) => v !== st)),
      });
    }
    for (const e of entitySel) {
      chips.push({
        key: `entity:${e}`,
        label: e,
        onRemove: () => setEntitySel(entitySel.filter((v) => v !== e)),
      });
    }
    return chips;
  }, [
    signupWindow,
    codeSel,
    statusSel,
    entitySel,
    scope,
    setScope,
    setSignupWindow,
    setCodeSel,
    setStatusSel,
    setEntitySel,
  ]);

  const resetFilters = () => {
    setSignupWindow(DEFAULT_SIGNUP_WINDOW);
    setCodeSel([]);
    setStatusSel([]);
    setEntitySel([]);
    setScope("campaigns");
  };

  const totalPages = Math.max(1, Math.ceil(rowsTotal / PAGE_SIZE));

  const getExportData = async () => {
    const res = await listPromoCodeBookings({
      codes: codeSel,
      kind,
      from: windowRange.from,
      to: windowRange.to,
      page: 1,
      pageSize: 200,
    });
    const items = res.success && res.data ? res.data.items : [];
    return {
      sheetName: "Bookings",
      columns: [
        { key: "Member" as const, label: "Member", width: 26 },
        { key: "Membership #" as const, label: "Membership #", width: 16 },
        { key: "Promo Code" as const, label: "Promo Code", width: 18 },
        { key: "Destination" as const, label: "Destination", width: 28 },
        { key: "Check In" as const, label: "Check In", width: 14 },
        { key: "Check Out" as const, label: "Check Out", width: 14 },
        { key: "Status" as const, label: "Status", width: 16 },
        { key: "ViewPoint #" as const, label: "ViewPoint #", width: 16 },
      ],
      rows: items.map((b) => ({
        Member: b.member_name ?? "",
        "Membership #": b.membership_number ?? "",
        "Promo Code": b.promo_code,
        Destination: b.entity_name ?? "",
        "Check In": b.check_in_date ?? "",
        "Check Out": b.check_out_date ?? "",
        Status: b.status ?? "",
        "ViewPoint #": b.booking_ref ?? "",
      })),
    };
  };

  return (
    <PageArrival>
      <Container fluid px={0} py={0}>
        <Group justify="space-between" align="center" mb="lg" wrap="wrap">
          <Group gap="sm">
            <Title order={3}>Bookings</Title>
            {sourceView === "core" && (
              <Badge variant="light" size="lg">
                <AnimatedNumber
                  value={(totals?.internal ?? 0) + (totals?.curated ?? 0)}
                />{" "}
                bookings
              </Badge>
            )}
          </Group>
          <Group gap="xs">
            {/*
              The two sources are not comparable subsets of one another, so this is a
              source selector rather than a filter — hence its own control, above the
              filters, which only apply to the core view.
            */}
            {/* With one view left there is nothing to switch between, so the
                control is hidden rather than shown with a single option. */}
            {SHOW_CORE_VIEW && (
              <SegmentedControl
                size="xs"
                value={sourceView}
                onChange={(v) => setSourceView(v as BookingSourceView)}
                data={[
                  { value: "core", label: "Core bookings" },
                  { value: "viewpoint", label: "Viewpoint import" },
                ]}
              />
            )}
            {/* Both are core-only: the Viewpoint panel has its own filters and
                export, scoped to imported data rather than promo-code signups. */}
            {sourceView === "core" && (
              <>
                <FilterTrigger
                  activeCount={filterChips.length}
                  onClick={() => setFiltersOpen(true)}
                />
                <ExportButton
                  label="Export Bookings"
                  filename="promo-code-bookings"
                  getData={getExportData}
                  section="bookings"
                />
              </>
            )}
          </Group>
        </Group>

        {sourceView === "viewpoint" && (
          <ViewpointBookingsPanel canImport={isSuperAdmin()} />
        )}

        {/* Everything below is the core view. The Viewpoint panel above renders
            instead when that source is selected — it brings its own filters,
            charts, table and export, scoped to imported data. */}
        {sourceView === "core" && (
          <ActiveFilterChips chips={filterChips} onReset={resetFilters} />
        )}

        {sourceView === "core" && (
          <FilterDrawer
            opened={filtersOpen}
            onClose={() => setFiltersOpen(false)}
            activeCount={filterChips.length}
            onReset={resetFilters}
          >
            <FilterSection
              icon={<IconCalendarStats size={15} />}
              title="Signup window"
              description="Scoped by when the member signed up, matching every other promo page."
            >
              <SignupWindowControl
                value={signupWindow}
                onChange={setSignupWindow}
              />
            </FilterSection>
            <FilterSection
              icon={<IconFilter size={15} />}
              title="Promo codes"
              description="Leave the picker empty to use the scope below."
            >
              {/* Made explicit rather than implied: the two scopes give different
                totals (53 vs 59 at the time of writing), and the gap is codes
                that belong to no campaign — worth being able to see, not a
                setting to bury. */}
              <SegmentedControl
                size="xs"
                fullWidth
                value={scope}
                onChange={(v) => setScope(v as "campaigns" | "all")}
                data={[
                  { value: "campaigns", label: "In a campaign" },
                  { value: "all", label: "All promo codes" },
                ]}
              />
              <MultiSelect
                label="Promo code"
                placeholder={codeSel.length ? undefined : "All promo codes"}
                data={codeOptions}
                value={codeSel}
                onChange={setCodeSel}
                searchable
                clearable
                hidePickedOptions
                limit={100}
              />
            </FilterSection>

            <FilterSection
              icon={<IconBuildingCommunity size={15} />}
              title="Booking"
              description="Status and destination. Options list only values that actually occur."
              withDivider={false}
            >
              <MultiSelect
                label="Booking status"
                placeholder={
                  statusSel.length ? undefined : "Live bookings (default)"
                }
                description="Selecting a cancelled status includes bookings that are hidden by default."
                data={statusOptions.map((v) => ({
                  value: v,
                  label: statusLabel(v),
                }))}
                value={statusSel}
                onChange={setStatusSel}
                searchable
                clearable
                hidePickedOptions
              />
              <MultiSelect
                label="Resort / property"
                placeholder={entitySel.length ? undefined : "All destinations"}
                data={entityOptions}
                value={entitySel}
                onChange={setEntitySel}
                searchable
                clearable
                hidePickedOptions
                limit={100}
              />
            </FilterSection>
          </FilterDrawer>
        )}

        {error && (
          <Text size="sm" c="red" mb="sm">
            {error}
          </Text>
        )}

        {sourceView === "core" && (
          <PageSection>
            <StatTiles tiles={statTiles} loading={analyticsLoading} />
          </PageSection>
        )}

        {sourceView === "core" && (
          <PageSection>
            <Card className={styles.sectionCard} p="md" mt="md">
              <Group justify="space-between" align="center" mb="sm">
                <Text fw={700} size="md">
                  Booking timing
                </Text>
                {/* Two genuinely different questions: when bookings were made, and
                  when the stays actually fall. */}
                <SegmentedControl
                  size="xs"
                  value={timing}
                  onChange={(v) => setTiming(v as Timing)}
                  data={[
                    { value: "booked", label: "Booked date" },
                    { value: "stay", label: "Stay month" },
                  ]}
                />
              </Group>
              {analyticsLoading ? (
                <Skeleton height={260} radius="md" />
              ) : timingRows.length < 2 ? (
                <Text size="sm" c="dimmed" py="xl" ta="center">
                  Not enough dated bookings to plot a trend.
                </Text>
              ) : (
                <CountryCampaignLines
                  data={timingRows}
                  series={bookingSeries}
                  xKey="label"
                  height={260}
                  hoveredIndex={null}
                  onHoverIndex={() => {}}
                  /* Chronological: ranking by volume would destroy the time axis. */
                  preserveOrder
                  pointNoun={timing === "booked" ? "days" : "months"}
                />
              )}
            </Card>
          </PageSection>
        )}

        {sourceView === "core" && (
          <PageSection>
            <Group align="stretch" gap="md" mt="md" grow wrap="wrap">
              <Card className={styles.sectionCard} p="md">
                <Text fw={700} size="md" mb="sm">
                  Bookings by area
                </Text>
                {analyticsLoading ? (
                  <Skeleton height={240} radius="md" />
                ) : areaRows.length === 0 ? (
                  <Text size="sm" c="dimmed" py="xl" ta="center">
                    No bookings in range.
                  </Text>
                ) : (
                  <CountryCampaignBars
                    data={areaRows}
                    series={bookingSeries}
                    xKey="label"
                    height={240}
                    hoveredIndex={null}
                    onHoverIndex={() => {}}
                  />
                )}
              </Card>

              <Card className={styles.sectionCard} p="md">
                <Text fw={700} size="md" mb="sm">
                  Booking status
                </Text>
                {analyticsLoading ? (
                  <Skeleton height={240} radius="md" />
                ) : statusSlices.length === 0 ? (
                  <Text size="sm" c="dimmed" py="xl" ta="center">
                    No bookings in range.
                  </Text>
                ) : (
                  <AnimatedDonut
                    data={statusSlices}
                    height={240}
                    centerLabel="Bookings"
                    unit="booking"
                  />
                )}
              </Card>
            </Group>
          </PageSection>
        )}

        {sourceView === "core" && (
          <PageSection>
            <Card className={styles.sectionCard} p="md" mt="md">
              <Text fw={700} size="md" mb="sm">
                Top destinations
              </Text>
              {analyticsLoading ? (
                <Skeleton height={260} radius="md" />
              ) : entityRows.length === 0 ? (
                <Text size="sm" c="dimmed" py="xl" ta="center">
                  No bookings in range.
                </Text>
              ) : (
                <CountryCampaignBars
                  data={entityRows}
                  series={bookingSeries}
                  xKey="label"
                  height={260}
                  hoveredIndex={null}
                  onHoverIndex={() => {}}
                />
              )}
            </Card>
          </PageSection>
        )}

        {sourceView === "core" && (
          <PageSection>
            <Card className={styles.sectionCard} p="md" mt="md" mb="md">
              <Group justify="space-between" align="center" mb="sm" wrap="wrap">
                <Group gap="sm" align="center">
                  <Text fw={700} size="md">
                    Bookings
                  </Text>
                  <Badge variant="light" size="sm">
                    {rowsTotal.toLocaleString()}
                  </Badge>
                </Group>
                {/* The list is per-kind because a booking can satisfy both the
                  internal and curated predicates, so a merged list could not
                  reconcile with either total. */}
                <SegmentedControl
                  size="xs"
                  value={kind}
                  onChange={(v) => setKind(v as "internal" | "curated")}
                  data={[
                    { value: "internal", label: "Internal" },
                    { value: "curated", label: "Curated" },
                  ]}
                />
              </Group>

              {rowsLoading ? (
                <Stack gap={6}>
                  {Array.from({ length: 8 }, (_, i) => (
                    <Skeleton key={i} height={34} radius="sm" />
                  ))}
                </Stack>
              ) : rows.length === 0 ? (
                <Text size="sm" c="dimmed" py="xl" ta="center">
                  No {kind} bookings for the current filters.
                </Text>
              ) : (
                <Box style={{ overflowX: "auto" }}>
                  <Table stickyHeader style={{ minWidth: 900 }}>
                    <Table.Thead className={styles.tableHead}>
                      <Table.Tr>
                        <Table.Th className={styles.th}>Member</Table.Th>
                        <Table.Th className={styles.th}>Membership #</Table.Th>
                        <Table.Th className={styles.th}>Promo code</Table.Th>
                        <Table.Th className={styles.th}>
                          {kind === "internal" ? "Property" : "Event"}
                        </Table.Th>
                        <Table.Th className={styles.th}>Country</Table.Th>
                        <Table.Th className={styles.th}>Check in</Table.Th>
                        <Table.Th className={styles.th}>Check out</Table.Th>
                        <Table.Th className={styles.th}>Status</Table.Th>
                        {/* viewpoint_booking_number — the upstream ViewPoint
                          reservation number, not one of ours. Named explicitly
                          because a bare "Ref" gives no way to know what to search
                          in ViewPoint. */}
                        <Table.Th className={styles.th}>ViewPoint #</Table.Th>
                      </Table.Tr>
                    </Table.Thead>
                    <Table.Tbody>
                      {rows.map((b) => (
                        <Table.Tr
                          key={b.booking_id}
                          className={styles.row}
                          /*
                           * Rows without a membership number aren't clickable:
                           * /admin/members/:accountId is looked up by membership
                           * number, so navigating without one would land on a
                           * broken page.
                           */
                          style={
                            b.membership_number
                              ? undefined
                              : { cursor: "default" }
                          }
                          onClick={
                            b.membership_number
                              ? () =>
                                  navigate(
                                    `/admin/members/${b.membership_number}?page=1&pageSize=50`,
                                  )
                              : undefined
                          }
                          title={
                            b.membership_number
                              ? "Open member profile"
                              : undefined
                          }
                        >
                          <Table.Td>{b.member_name || "—"}</Table.Td>
                          <Table.Td className={styles.num}>
                            {b.membership_number || "—"}
                          </Table.Td>
                          <Table.Td>
                            <Badge variant="light" size="sm">
                              {b.promo_code}
                            </Badge>
                          </Table.Td>
                          <Table.Td>{b.entity_name || "—"}</Table.Td>
                          <Table.Td>{b.country || "—"}</Table.Td>
                          <Table.Td className={styles.num}>
                            {formatDate(b.check_in_date)}
                          </Table.Td>
                          <Table.Td className={styles.num}>
                            {formatDate(b.check_out_date)}
                          </Table.Td>
                          <Table.Td>
                            {b.status ? (
                              <Badge
                                variant="light"
                                size="sm"
                                color={
                                  DEAD_STATUSES.has(b.status)
                                    ? "red"
                                    : WARN_STATUSES.has(b.status)
                                      ? "orange"
                                      : "green"
                                }
                              >
                                {statusLabel(b.status)}
                              </Badge>
                            ) : (
                              "—"
                            )}
                          </Table.Td>
                          <Table.Td className={styles.num}>
                            {b.booking_ref || "—"}
                          </Table.Td>
                        </Table.Tr>
                      ))}
                    </Table.Tbody>
                  </Table>
                </Box>
              )}

              {totalPages > 1 && (
                <Group justify="center" mt="md">
                  <Pagination
                    value={page}
                    onChange={setPage}
                    total={totalPages}
                    size="sm"
                  />
                </Group>
              )}
            </Card>
          </PageSection>
        )}

        {!accessScope.read && (
          <Text size="sm" c="dimmed">
            You do not have permission to view bookings.
          </Text>
        )}
      </Container>
    </PageArrival>
  );
}
