"use client";

/**
 * Analytics panel for a campaign: bookings split, signups per promo code, and
 * the country breakdown.
 *
 * Colours come from the promo theme rather than a fixed scale, so this panel
 * follows the accent the same way the campaigns dashboard does. Outside a
 * `PromoThemeProvider` the theme hook falls back to its defaults, so the panel
 * still renders correctly on pages that don't opt into the theme.
 *
 * Slice colours are ranked by signups descending, which is the same order
 * `CountryBreakdownBarChart` ranks by — so a promo code keeps one colour across
 * the donut and the bars. Parity holds for the top 8; beyond that the bar chart
 * buckets the tail into a neutral "Other" while the donut still lists each code,
 * so the colours diverge past that point.
 */

import {
  Badge,
  Box,
  Button,
  Card,
  Group,
  Modal,
  SimpleGrid,
  Stack,
  Text,
} from "@mantine/core";
import { IconChevronRight } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import type { CampaignAnalyticsResponse } from "@/lib/features/promo-code-campaigns/types";
import { usePromoTheme } from "./appearance";
import { AnimatedDonut, type DonutDatum } from "./charts/AnimatedDonut";
import { CountryBreakdownBarChart } from "./CountryBreakdownBarChart";
import { AnimatedNumber, Reveal } from "./motion";
import styles from "./promo.module.css";

/** Legend entries shown inline before the "Show N more" modal takes over. */
const LEGEND_LIMIT = 5;

/**
 * How many promo codes get their own arc before the rest are grouped.
 *
 * A donut cannot show a long tail. With 117 codes over 15,210 signups, a code with
 * five signups is 0.03% — a 0.12 degree sliver that cannot be seen, hovered or
 * clicked, and fanning the palette across 117 hues leaves even the large slices
 * looking like each other. Twelve is about the limit for arcs that stay
 * distinguishable, so beyond that the tail becomes one "Others" slice and the full
 * list stays available in the legend and its modal.
 */
const MAX_DONUT_SLICES = 12;

/** The grouped-tail slice. Named so the click handler and legend can recognise it. */
const OTHERS_LABEL = "Others";

/**
 * Stands in for an id on the grouped slice.
 *
 * The donut and the legend only offer a click when a slice carries an id, so the
 * grouped arc needs one to be interactive at all. It is deliberately not a real id
 * and is never resolved as one — see the guard in `drillIn` below.
 */
const OTHERS_SENTINEL_ID = "__others__";

/*
 * The grouped tail's colour comes from `aggregateSliceColor`, derived from the
 * current accent, so it shifts with the theme instead of being a fixed grey that
 * looked washed out beside a gold ramp. It is resolved inside the component because
 * the accent is a runtime value — see `tailPaint` below.
 *
 * The same colour is used for the codes *inside* the group, so a legend row's swatch
 * still tells the truth: it says "this code is part of that arc", rather than
 * promising an arc of its own that isn't drawn.
 */

interface CampaignAnalyticsCardProps {
  analytics: CampaignAnalyticsResponse | null;
  /**
   * Promo-code ids that pass the parent's current filters. Charts only render
   * slices/series for these ids so analytics react to the same filters as the
   * table. Pass a set of every id to show everything.
   */
  visiblePromoCodeIds: Set<string>;

  countryFilter?: string[];
  showBookingsAnalytics?: boolean;

  /**
   * Drills into a single promo code from the signups donut and its legend.
   *
   * Every page that renders this card supplies one, each pointing at whichever
   * detail route it owns: the campaign page uses its nested
   * `/promo-code-campaigns/:id/promo-codes/:code`, while the cross-campaign pages
   * use the campaign-free `/admin/promo-codes/:code`. It stays optional so the
   * card can be dropped somewhere with no detail route to offer, in which case the
   * legend is inert rather than clickable-but-broken.
   *
   * Receives `name` as well as `id` because both detail routes are keyed by the
   * code itself, which is what `promo_code_name` holds.
   */
  onPromoCodeClick?: (promoCode: { id: string; name: string }) => void;
}

export function CampaignAnalyticsCard({
  analytics,
  visiblePromoCodeIds,
  countryFilter,
  showBookingsAnalytics = true,
  onPromoCodeClick,
}: CampaignAnalyticsCardProps) {
  const hasCountryFilter = Boolean(countryFilter && countryFilter.length > 0);
  const [showAllSlices, setShowAllSlices] = useState(false);
  const { series: seriesPalette, roles, muted } = usePromoTheme();

  /*
   * The grouped-tail colour comes from the theme, so it changes with the accent
   * alongside every other muted slice on these pages rather than being this chart's
   * own idea of neutral.
   */
  const tailPaint = muted;

  const signupsByCodeAllCountries = useMemo(
    () =>
      new Map(
        (analytics?.analytics.byPromoCode ?? []).map(
          (row) => [row.promo_code_id, row.signups] as const,
        ),
      ),
    [analytics],
  );

  const signupsByCodeInFilteredCountries = useMemo(() => {
    const m = new Map<string, number>();
    if (!analytics || !hasCountryFilter) return m;
    const countrySet = new Set(countryFilter);
    for (const row of analytics.analytics.byCountryPromoCode) {
      if (!row.country || !countrySet.has(row.country)) continue;
      m.set(row.promo_code_id, (m.get(row.promo_code_id) ?? 0) + row.signups);
    }
    return m;
  }, [analytics, hasCountryFilter, countryFilter]);

  /*
   * Ranked slices with their theme colours already resolved.
   *
   * The palette is sized to the number of *non-zero* slices, not the number of
   * promo codes: a campaign with 30 codes where only 4 have signups should get
   * four well-separated hues, not four neighbours from a 30-way fan.
   */
  const pieData = useMemo<Array<DonutDatum & { pct: number }>>(() => {
    if (!analytics) return [];
    const signupsByCode = hasCountryFilter
      ? signupsByCodeInFilteredCountries
      : signupsByCodeAllCountries;
    const ranked = analytics.promo_codes
      .filter((promo) => visiblePromoCodeIds.has(promo.promo_code_id))
      .map((promo) => ({
        // Carried so a click resolves to a promo code rather than to a label —
        // see DonutDatum.id.
        id: promo.promo_code_id,
        name: promo.promo_code_name,
        value: signupsByCode.get(promo.promo_code_id) ?? 0,
      }))
      .sort((a, b) => b.value - a.value);

    const positives = ranked.filter((s) => s.value > 0);
    const total = positives.reduce((sum, s) => sum + s.value, 0);

    /*
     * The palette is sized to the arcs the donut will actually draw, not to every
     * code with signups. Fanning it across all 117 gave neighbouring codes hues a
     * few degrees apart, which is why the chart read as one colour — twelve hues
     * over twelve arcs are properly separated.
     */
    const charted = Math.min(positives.length, MAX_DONUT_SLICES);
    const palette = seriesPalette(Math.max(1, charted));

    return ranked.map((slice, i) => {
      /*
       * Anything past the charted head is grey: it is inside the Others arc, not an
       * arc of its own. Zero-signup codes are grey for the same reason — they never
       * reach the donut at all.
       */
      const inTail = i >= charted || slice.value === 0;
      const paint = palette[i] ?? palette[palette.length - 1];
      return {
        id: slice.id,
        name: slice.name,
        value: slice.value,
        color: inTail ? tailPaint.from : paint.from,
        colorTo: inTail ? tailPaint.to : paint.to,
        pct: total > 0 ? (slice.value / total) * 100 : 0,
      };
    });
  }, [
    analytics,
    visiblePromoCodeIds,
    hasCountryFilter,
    signupsByCodeInFilteredCountries,
    signupsByCodeAllCountries,
    seriesPalette,
    tailPaint,
  ]);

  const signupSlices = useMemo(
    () => pieData.filter((s) => s.value > 0),
    [pieData],
  );

  /**
   * What the donut draws: the largest codes individually, the rest as one arc.
   *
   * `signupSlices` is still the full truth and drives the legend and its modal —
   * nothing is hidden, it just stops pretending 117 arcs can be read. The grouped
   * slice carries no `id`, so it cannot navigate to a promo code; clicking it opens
   * the full list instead, which is the question a big grey arc actually raises.
   */
  const donutSlices = useMemo(() => {
    if (signupSlices.length <= MAX_DONUT_SLICES) return signupSlices;

    // One place is kept for Others, so the arc count never exceeds the maximum.
    const head = signupSlices.slice(0, MAX_DONUT_SLICES - 1);
    const tail = signupSlices.slice(MAX_DONUT_SLICES - 1);
    const value = tail.reduce((sum, s) => sum + s.value, 0);
    return [
      ...head,
      {
        /*
         * A sentinel, not a promo code id. It exists because the donut and the
         * legend both gate their click affordance on an id being present, and
         * without one the grouped arc would look inert. Every handler matches on
         * `name === OTHERS_LABEL` before it reads an id, so this value is never
         * treated as something to navigate to.
         */
        id: OTHERS_SENTINEL_ID,
        name: OTHERS_LABEL,
        value,
        color: tailPaint.from,
        colorTo: tailPaint.to,
        pct: tail.reduce((sum, s) => sum + s.pct, 0),
      },
    ];
  }, [signupSlices, tailPaint]);

  /** How many codes the Others arc stands for, for its legend row. */
  const groupedCount = Math.max(
    0,
    signupSlices.length - (MAX_DONUT_SLICES - 1),
  );
  const groupedValue = donutSlices.length
    ? donutSlices[donutSlices.length - 1].name === OTHERS_LABEL
      ? donutSlices[donutSlices.length - 1].value
      : 0
    : 0;

  /*
   * Turns a slice into a drill-in, when the page supplied a destination.
   *
   * A slice with no id can't resolve to a promo code, so it stays inert rather
   * than navigating somewhere arbitrary. Returns undefined when there is nothing
   * to click, which is what the donut and the legend rows key their affordances
   * off — a pointer cursor that promises navigation has to mean it.
   */
  const drillIn = useMemo(() => {
    if (!onPromoCodeClick) return undefined;
    return (slice: { id?: string; name: string }) => {
      if (!slice.id) return;
      // The grouped arc's id is a sentinel, not a promo code. Refused here as well
      // as at each call site, so it can never be resolved as one.
      if (slice.id === OTHERS_SENTINEL_ID) return;
      onPromoCodeClick({ id: slice.id, name: slice.name });
    };
  }, [onPromoCodeClick]);

  const bookingTotals = useMemo(() => {
    if (!analytics) return { internal: 0, external: 0, total: 0 };
    let internal = Number(analytics.analytics.totalInternalBookings ?? 0);
    let external = Number(analytics.analytics.totalExternalBookings ?? 0);

    if (internal === 0 && external === 0 && analytics.analytics.byPromoCode) {
      for (const row of analytics.analytics.byPromoCode) {
        if (visiblePromoCodeIds.has(row.promo_code_id)) {
          internal += Number(row.internal_bookings || 0);
          external += Number(row.external_bookings || 0);
        }
      }
    }
    return { internal, external, total: internal + external };
  }, [analytics, visiblePromoCodeIds]);

  const bookingSlices = useMemo<DonutDatum[]>(
    () =>
      [
        {
          name: "Karma Subito bookings",
          value: bookingTotals.internal,
          color: roles.internal.from,
          colorTo: roles.internal.to,
        },
        {
          name: "Curated bookings",
          value: bookingTotals.external,
          color: roles.external.from,
          colorTo: roles.external.to,
        },
      ].filter((s) => s.value > 0),
    [bookingTotals, roles],
  );

  const totalSignups = useMemo(() => {
    if (!analytics) return 0;
    return analytics.promo_codes
      .filter((promo) => visiblePromoCodeIds.has(promo.promo_code_id))
      .reduce(
        (sum, promo) =>
          sum + (signupsByCodeAllCountries.get(promo.promo_code_id) ?? 0),
        0,
      );
  }, [analytics, visiblePromoCodeIds, signupsByCodeAllCountries]);

  const barEntities = useMemo(
    () =>
      (analytics?.promo_codes ?? [])
        .filter((p) => visiblePromoCodeIds.has(p.promo_code_id))
        .map((p) => ({ id: p.promo_code_id, name: p.promo_code_name })),
    [analytics, visiblePromoCodeIds],
  );
  const barRows = useMemo(
    () =>
      (analytics?.analytics.byCountryPromoCode ?? [])
        .filter(
          (row) =>
            visiblePromoCodeIds.has(row.promo_code_id) &&
            (!hasCountryFilter ||
              (row.country && countryFilter!.includes(row.country))),
        )
        .map((row) => ({
          country: row.country,
          id: row.promo_code_id,
          signups: row.signups,
        })),
    [analytics, visiblePromoCodeIds, hasCountryFilter, countryFilter],
  );

  return (
    <>
      <Card className={styles.sectionCard} p="md" mb="md">
        <Group justify="space-between" align="center" mb="md">
          <Text fw={700} size="md">
            Analytics
          </Text>
          <Badge variant="light" size="md" radius="sm">
            <AnimatedNumber value={totalSignups} /> total signups
          </Badge>
        </Group>

        <SimpleGrid
          cols={showBookingsAnalytics ? { base: 1, md: 2 } : 1}
          spacing="lg"
          mb="lg"
        >
          {showBookingsAnalytics && (
            <Reveal>
              <Stack gap="xs">
                <Group justify="space-between" align="center" wrap="wrap">
                  <Text size="md" fw={700}>
                    Bookings
                  </Text>
                  <Group gap={6}>
                    <Badge
                      variant="light"
                      size="xs"
                      className={styles.dataBadge}
                      data-role="internal"
                    >
                      Karma Subito: {bookingTotals.internal}
                    </Badge>
                    <Badge
                      variant="light"
                      size="xs"
                      className={styles.dataBadge}
                      data-role="curated"
                    >
                      Curated: {bookingTotals.external}
                    </Badge>
                  </Group>
                </Group>

                {bookingSlices.length > 0 ? (
                  <AnimatedDonut
                    data={bookingSlices}
                    height={220}
                    centerLabel="Total bookings"
                    unit="booking"
                  />
                ) : (
                  <Stack align="center" justify="center" py="xl">
                    <Text size="sm" c="dimmed" ta="center">
                      No booking activity recorded for this campaign yet.
                    </Text>
                  </Stack>
                )}
              </Stack>
            </Reveal>
          )}

          <Reveal delay={showBookingsAnalytics ? 0.08 : 0}>
            <Stack gap="xs">
              <Text size="md" fw={700}>
                Signups per promo code
              </Text>
              {signupSlices.length === 0 ? (
                <Text c="dimmed" size="sm" py="md">
                  No signups in range.
                </Text>
              ) : (
                <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
                  <AnimatedDonut
                    data={donutSlices}
                    height={220}
                    centerLabel="Signups"
                    unit="signup"
                    /* The grouped arc has no promo code to open, so it opens the
                       full list — which is what it is standing in for. */
                    onSliceClick={(slice) => {
                      if (slice.name === OTHERS_LABEL) {
                        setShowAllSlices(true);
                        return;
                      }
                      drillIn?.(slice);
                    }}
                  />
                  <Stack gap={2} justify="center">
                    {pieData.slice(0, LEGEND_LIMIT).map((slice) => (
                      <LegendRow
                        key={slice.name}
                        slice={slice}
                        onDrillIn={drillIn}
                      />
                    ))}
                    {/* The grouped arc gets a legend row of its own. Without it the
                        donut shows a large grey wedge that nothing on the right
                        accounts for. */}
                    {groupedCount > 1 && (
                      <LegendRow
                        slice={{
                          id: OTHERS_SENTINEL_ID,
                          name: `${OTHERS_LABEL} (${groupedCount} codes)`,
                          value: groupedValue,
                          color: tailPaint.from,
                          colorTo: tailPaint.to,
                          pct:
                            signupSlices.length > 0
                              ? signupSlices
                                  .slice(MAX_DONUT_SLICES - 1)
                                  .reduce((sum, s) => sum + s.pct, 0)
                              : 0,
                        }}
                        onDrillIn={() => setShowAllSlices(true)}
                      />
                    )}
                    {pieData.length > LEGEND_LIMIT && (
                      <Group justify="flex-start" mt={4}>
                        <Button
                          variant="light"
                          size="xs"
                          radius="xl"
                          rightSection={<IconChevronRight size={14} />}
                          onClick={() => setShowAllSlices(true)}
                        >
                          Show {pieData.length - LEGEND_LIMIT} more
                        </Button>
                      </Group>
                    )}
                  </Stack>
                </SimpleGrid>
              )}
            </Stack>
          </Reveal>
        </SimpleGrid>

        <Box mt="md">
          <hr className={styles.gradientRule} />
          <Box mt="lg">
            <CountryBreakdownBarChart
              title="Country × Promo Code"
              entities={barEntities}
              rows={barRows}
            />
          </Box>
        </Box>
      </Card>

      <Modal
        opened={showAllSlices}
        onClose={() => setShowAllSlices(false)}
        title="All promo codes — signups"
        size="lg"
        centered
      >
        <Stack gap={2} mah={520} style={{ overflowY: "auto" }}>
          {pieData.map((slice) => (
            <LegendRow
              key={slice.name}
              slice={slice}
              onDrillIn={
                drillIn
                  ? (s) => {
                      // Closed first: navigating out from under an open modal
                      // leaves the overlay and its scroll lock behind.
                      setShowAllSlices(false);
                      drillIn(s);
                    }
                  : undefined
              }
              py={4}
              percentWidth={48}
            />
          ))}
        </Stack>
      </Modal>
    </>
  );
}

interface LegendRowProps {
  slice: DonutDatum & { pct: number };
  /** Absent for a read-only legend; present makes the row a drill-in. */
  onDrillIn?: (slice: DonutDatum) => void;
  py?: number;
  percentWidth?: number;
}

/**
 * One legend entry, shared by the inline legend and the "show all" modal.
 *
 * Both listed the same slice with the same markup, so making one of them
 * clickable would have meant maintaining the keyboard and ARIA handling twice.
 */
function LegendRow({ slice, onDrillIn, py, percentWidth }: LegendRowProps) {
  // Read here rather than threaded through as a prop: LegendRow is used from three
  // places in this file, and passing the same themed colour to each of them is the
  // kind of wiring that drifts.
  const { muted } = usePromoTheme();
  const mutedSwatch = `linear-gradient(135deg, ${muted.from}, ${muted.to})`;
  const drillable = Boolean(onDrillIn && slice.id);
  const activate = () => {
    if (drillable) onDrillIn!(slice);
  };

  return (
    <Group
      className={styles.legendRow}
      justify="space-between"
      gap="xs"
      wrap="nowrap"
      py={py}
      /* A div with button semantics rather than `component="button"`: Mantine's
         Group does not type a dynamic component, and role + tabIndex + key
         handling gives the same keyboard and screen-reader behaviour. Matches
         BreakdownPanel's legend. */
      role={drillable ? "button" : undefined}
      tabIndex={drillable ? 0 : undefined}
      onClick={drillable ? activate : undefined}
      onKeyDown={
        drillable
          ? (e) => {
              // Enter and Space are what a real button responds to.
              if (e.key === "Enter" || e.key === " ") {
                e.preventDefault();
                activate();
              }
            }
          : undefined
      }
      style={drillable ? { cursor: "pointer" } : undefined}
      title={drillable ? `View ${slice.name}` : undefined}
    >
      <Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
        <span
          className={styles.legendSwatch}
          style={{
            background:
              slice.value > 0
                ? `linear-gradient(135deg, ${slice.color}, ${slice.colorTo ?? slice.color})`
                : // A zero-signup code has no arc, so its swatch is muted — themed,
                  // not a fixed grey.
                  mutedSwatch,
          }}
        />
        <Text size="sm" lineClamp={1}>
          {slice.name}
        </Text>
      </Group>
      <Group gap={percentWidth ? 8 : 6} wrap="nowrap">
        <Text size="sm" fw={600}>
          {slice.value}
        </Text>
        <Text
          size="xs"
          c="dimmed"
          w={percentWidth}
          ta={percentWidth ? "right" : undefined}
        >
          {percentWidth
            ? `${slice.pct.toFixed(1)}%`
            : `(${slice.pct.toFixed(1)}%)`}
        </Text>
        {/* Sits in the layout permanently so hovering never reflows the row; it
            only goes from muted to accent. */}
        {drillable && (
          <IconChevronRight
            size={15}
            stroke={2}
            className={styles.legendChevron}
          />
        )}
      </Group>
    </Group>
  );
}
