"use client";

/**
 * Page-local appearance settings for the promo-code pages.
 *
 * Controls three things, all persisted per browser in localStorage (nothing is
 * written to the server, so there's no migration, API or role privilege):
 *   - accent      — anchors both the chrome and the chart palette
 *   - fill style  — solid or gradient fills for series, bars and tiles
 *   - background  — plain / accent wash / frosted blooms
 *
 * Chart series are *derived* from the accent rather than set to it — see
 * ./palette.ts for why a single accent colour would make stacked bars and donuts
 * unreadable.
 *
 * Exposed through context so deeply nested charts (the country breakdown, the
 * table badges) can read the palette without prop drilling. Components used
 * outside the provider — e.g. CampaignAnalyticsCard on the detail page — fall
 * back to the default indigo/solid theme.
 */

import {
  getConsoleSetting,
  putConsoleSetting,
} from "@/lib/features/console-settings/query";
import { useRoleAccess } from "@/hooks/useRoleAccess";
import {
  ActionIcon,
  ColorInput,
  Group,
  Popover,
  SegmentedControl,
  Stack,
  Text,
  Tooltip,
  useMantineColorScheme,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconCheck, IconPalette } from "@tabler/icons-react";
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from "react";
import { motion } from "./motion";
import {
  ACCENT_PRESETS,
  accentCompanion,
  accentVariants,
  buildAccentScale,
  buildMutedColor,
  hexToOklch,
  buildRolePalette,
  buildSeriesPalette,
  DEFAULT_ACCENT,
  type PromoAccent,
  type PromoAccentStyle,
  type PromoChartDepth,
  type PromoPaletteMode,
  type PromoSurface,
  type SeriesColor,
  surfaceVars,
} from "./palette";
import styles from "./promo.module.css";

export type {
  PromoAccent,
  PromoAccentStyle,
  PromoChartDepth,
  PromoPaletteMode,
  PromoSurface,
} from "./palette";
export type PromoBackground = "plain" | "gradient" | "glass";

const BACKGROUNDS: Array<{
  value: PromoBackground;
  label: string;
  hint: string;
}> = [
  { value: "plain", label: "Plain", hint: "Flat console background" },
  { value: "gradient", label: "Gradient", hint: "Soft accent wash" },
  { value: "glass", label: "Glass tint", hint: "Frosted colour blooms" },
];

interface PromoTheme {
  accent: PromoAccent;
  accentStyle: PromoAccentStyle;
  chartDepth: PromoChartDepth;
  surface: PromoSurface;
  paletteMode: PromoPaletteMode;
  background: PromoBackground;
  /** `count` mutually distinguishable colours anchored at the accent. */
  series: (count: number) => SeriesColor[];
  /** Stable colours for the internal/curated booking pair. */
  roles: { internal: SeriesColor; external: SeriesColor };
  /**
   * For anything that isn't a real category — "Others", "None", "Pending",
   * "No Country Info", "unmatched".
   *
   * Exposed on the theme rather than imported per call site so every one of them
   * moves together when the accent changes. Each of these used to hardcode Mantine
   * grey, which is how they ended up being the one thing a theme change didn't
   * touch.
   */
  muted: SeriesColor;
}

const DEFAULT_STYLE: PromoAccentStyle = "solid";
const DEFAULT_DEPTH: PromoChartDepth = "glossy";
const DEFAULT_SURFACE: PromoSurface = "soft";
const DEFAULT_PALETTE: PromoPaletteMode = "multi";

const PromoThemeContext = createContext<PromoTheme | null>(null);

/*
 * How many components are currently applying the theme variables.
 *
 * The hook is mounted in more than one place (the promo layout, plus the deeper
 * campaign routes that sit outside it). Without a count, the first one to unmount
 * would strip the variables while another consumer still needed them, and the page
 * would silently revert to default colours mid-navigation.
 */
let themeConsumers = 0;

/** The six values that make up a theme, as stored server-side. */
export interface StoredAppearance {
  background: PromoBackground;
  accent: PromoAccent;
  accentStyle: PromoAccentStyle;
  chartDepth: PromoChartDepth;
  surface: PromoSurface;
  paletteMode: PromoPaletteMode;
}

/*
 * The in-flight fetch of the shared theme, shared across hook instances.
 *
 * The hook is mounted in more than one place (the promo layout plus the campaign
 * detail routes), and without this each instance would issue its own request for
 * the same row on every navigation.
 */
let sharedThemeRequest: Promise<StoredAppearance | null> | null = null;

/*
 * Quiet period before a change is published, in ms.
 *
 * The accent uses a Mantine `ColorInput`, whose `onChange` fires on every drag
 * frame and every keystroke of a typed hex. Publishing inline would issue a PUT
 * per event — dozens for one colour pick. Local state still updates instantly,
 * so the picker stays responsive; only the shared write waits for the user to
 * settle.
 */
const PUBLISH_DEBOUNCE_MS = 500;

function fetchSharedTheme(): Promise<StoredAppearance | null> {
  if (!sharedThemeRequest) {
    sharedThemeRequest = getConsoleSetting<StoredAppearance>("promo-appearance")
      .then((res) => (res.success ? (res.data?.value ?? null) : null))
      // A failed read must not break the page: fall back to whatever the
      // browser already had, which is why this resolves rather than rejects.
      .catch(() => null);
  }
  return sharedThemeRequest;
}

/** Reads the page theme, falling back to the default outside a provider. */
export function usePromoTheme(): PromoTheme {
  const ctx = useContext(PromoThemeContext);
  return useMemo(() => {
    if (ctx) return ctx;
    return {
      accent: DEFAULT_ACCENT,
      accentStyle: DEFAULT_STYLE,
      chartDepth: DEFAULT_DEPTH,
      surface: DEFAULT_SURFACE,
      paletteMode: DEFAULT_PALETTE,
      background: "plain" as PromoBackground,
      series: (count: number) =>
        buildSeriesPalette(
          DEFAULT_ACCENT,
          count,
          DEFAULT_STYLE,
          DEFAULT_PALETTE,
        ),
      roles: buildRolePalette(DEFAULT_ACCENT, DEFAULT_STYLE, DEFAULT_PALETTE),
      muted: buildMutedColor(DEFAULT_ACCENT),
    };
  }, [ctx]);
}

export interface Appearance {
  background: PromoBackground;
  accent: PromoAccent;
  accentStyle: PromoAccentStyle;
  chartDepth: PromoChartDepth;
  surface: PromoSurface;
  paletteMode: PromoPaletteMode;
  setBackground: (value: PromoBackground) => void;
  setAccent: (value: PromoAccent) => void;
  setAccentStyle: (value: PromoAccentStyle) => void;
  setChartDepth: (value: PromoChartDepth) => void;
  setSurface: (value: PromoSurface) => void;
  setPaletteMode: (value: PromoPaletteMode) => void;
  theme: PromoTheme;
  /** Spread onto the page's outermost element. */
  surfaceProps: { className: string; "data-bg": PromoBackground };
}

export function usePromoAppearance(): Appearance {
  const [background, setBackground] = useLocalStorage<PromoBackground>({
    key: "promo.appearance.background",
    defaultValue: "gradient",
    getInitialValueInEffect: true,
  });
  const [accent, setAccent] = useLocalStorage<PromoAccent>({
    key: "promo.appearance.accent",
    defaultValue: DEFAULT_ACCENT,
    getInitialValueInEffect: true,
  });
  const [accentStyle, setAccentStyle] = useLocalStorage<PromoAccentStyle>({
    key: "promo.appearance.fill",
    defaultValue: DEFAULT_STYLE,
    getInitialValueInEffect: true,
  });
  const [chartDepth, setChartDepth] = useLocalStorage<PromoChartDepth>({
    key: "promo.appearance.depth",
    defaultValue: DEFAULT_DEPTH,
    getInitialValueInEffect: true,
  });
  const [surface, setSurface] = useLocalStorage<PromoSurface>({
    key: "promo.appearance.surface",
    defaultValue: DEFAULT_SURFACE,
    getInitialValueInEffect: true,
  });
  const [paletteMode, setPaletteMode] = useLocalStorage<PromoPaletteMode>({
    key: "promo.appearance.palette",
    defaultValue: DEFAULT_PALETTE,
    getInitialValueInEffect: true,
  });

  const { isSuperAdmin } = useRoleAccess();

  /*
   * Pull the shared theme once and adopt it.
   *
   * localStorage is now only a paint cache — it lets the first frame use the
   * last-known theme instead of flashing the default while this request is in
   * flight. The server row is authoritative, so whatever comes back wins, which
   * is what makes one super admin's choice apply to every user.
   */
  const [serverLoaded, setServerLoaded] = useState(false);
  useEffect(() => {
    let cancelled = false;
    void fetchSharedTheme().then((shared) => {
      if (cancelled) return;
      if (shared) {
        // Each value is only written when it differs, so adopting the shared
        // theme doesn't churn six localStorage writes on every mount.
        if (shared.background) setBackground(shared.background);
        if (shared.accent) setAccent(shared.accent);
        if (shared.accentStyle) setAccentStyle(shared.accentStyle);
        if (shared.chartDepth) setChartDepth(shared.chartDepth);
        if (shared.surface) setSurface(shared.surface);
        if (shared.paletteMode) setPaletteMode(shared.paletteMode);
      }
      setServerLoaded(true);
    });
    return () => {
      cancelled = true;
    };
    // Intentionally mount-only: the setters are stable and re-running this would
    // undo an edit the admin just made.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  /*
   * Publish a change to every user, when the editor is allowed to.
   *
   * Writes are skipped until the initial read has landed — otherwise the adopt
   * effect above could race and push the cached value back over a newer shared
   * one. Non-super-admins never reach here (the menu is hidden for them), and
   * core rejects the write with a 403 regardless.
   */
  const publishTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const pending = useRef<StoredAppearance | null>(null);

  // Flush any queued change if the page is left mid-debounce, so a pick made and
  // navigated away from within half a second still reaches everyone.
  useEffect(
    () => () => {
      if (publishTimer.current) {
        clearTimeout(publishTimer.current);
        publishTimer.current = null;
        const queued = pending.current;
        if (queued)
          void putConsoleSetting("promo-appearance", queued).catch(() => {});
      }
    },
    [],
  );

  const publish = useCallback(
    (patch: Partial<StoredAppearance>) => {
      if (!serverLoaded || !isSuperAdmin()) return;
      const next: StoredAppearance = {
        background,
        accent,
        accentStyle,
        chartDepth,
        surface,
        paletteMode,
        ...patch,
      };
      // Keep the de-duped cache in step so a later mount doesn't re-adopt the
      // value this admin just replaced.
      sharedThemeRequest = Promise.resolve(next);
      pending.current = next;
      if (publishTimer.current) clearTimeout(publishTimer.current);
      publishTimer.current = setTimeout(() => {
        publishTimer.current = null;
        const queued = pending.current;
        pending.current = null;
        if (!queued) return;
        void putConsoleSetting("promo-appearance", queued).catch(() => {
          // Saving is best-effort: the local change already applied, and the
          // next page load re-reads the shared value.
        });
      }, PUBLISH_DEBOUNCE_MS);
    },
    [
      serverLoaded,
      isSuperAdmin,
      background,
      accent,
      accentStyle,
      chartDepth,
      surface,
      paletteMode,
    ],
  );

  // Written to the document element, not the page wrapper: the filter Drawer and
  // the chart tooltips render through portals at <body>, so they'd never inherit
  // a variable scoped to the page container.
  useEffect(() => {
    const root = document.documentElement;
    const roles = buildRolePalette(accent, accentStyle, paletteMode);
    // Derived from the accent's own hex rather than a Mantine colour name — the
    // accent is now any colour the user picks, so there is no named scale to
    // interpolate against.
    const v = accentVariants(accent);
    const scale = buildAccentScale(accent);
    // OKLCH lightness: above this, white text on the accent is unreadable.
    const accentIsLight = hexToOklch(accent).l > 0.62;
    const vars: Record<string, string> = {
      "--promo-accent": v.base,
      "--promo-accent-hover": v.hover,
      "--promo-accent-soft": v.soft,
      "--promo-accent-border": v.border,
      "--promo-accent-text": v.text,
      // Consumed by the table's booking badges, which have no direct access to
      // the palette but must match the bookings donut.
      "--role-internal": roles.internal.solid,
      "--role-curated": roles.external.solid,
      // Second bloom in the glass/gradient backdrop. Previously hardcoded grape,
      // which ignored the accent entirely.
      "--promo-accent-alt": accentCompanion(accent, paletteMode),

      /*
       * Mantine's primary colour, so its own components — buttons, tab pills,
       * badges — follow the accent.
       *
       * These specific variables are the mechanism, not a nested theme: only the
       * ROOT MantineProvider emits `--mantine-color-<name>-*`, so registering a
       * colour on a nested MantineThemeProvider left components referencing
       * variables that were never defined — which rendered active tab pills as
       * invisible text on a transparent background. Overriding the documented
       * `--mantine-primary-color-*` set needs no registration and simply inherits.
       *
       * Set on <html> rather than the page wrapper because Drawer, Modal and
       * Popover render through portals at <body>; the cleanup below restores the
       * console default on leaving the page.
       */
      "--mantine-primary-color-filled": scale[6],
      "--mantine-primary-color-filled-hover": scale[7],
      "--mantine-primary-color-light": `color-mix(in srgb, ${scale[6]} 12%, transparent)`,
      "--mantine-primary-color-light-hover": `color-mix(in srgb, ${scale[6]} 20%, transparent)`,
      "--mantine-primary-color-light-color": scale[7],
      // Label colour on a filled surface, from the accent's own lightness — the
      // contrast decision Mantine could not make once it failed to parse oklch.
      "--mantine-primary-color-contrast": accentIsLight ? "#1a1a1a" : "#ffffff",
      // Gloss + shadow strength, so surfaces respond to the theme too.
      ...surfaceVars(surface),
    };
    themeConsumers += 1;
    for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
    return () => {
      themeConsumers -= 1;
      // Only the last consumer clears up, so navigating between promo pages does
      // not briefly drop the theme.
      if (themeConsumers <= 0) {
        themeConsumers = 0;
        for (const k of Object.keys(vars)) root.style.removeProperty(k);
      }
    };
  }, [accent, accentStyle, surface, paletteMode]);

  const theme = useMemo<PromoTheme>(
    () => ({
      accent,
      accentStyle,
      chartDepth,
      surface,
      paletteMode,
      background,
      series: (count: number) =>
        buildSeriesPalette(accent, count, accentStyle, paletteMode),
      roles: buildRolePalette(accent, accentStyle, paletteMode),
      muted: buildMutedColor(accent),
    }),
    [accent, accentStyle, chartDepth, surface, paletteMode, background],
  );

  /*
   * Setters that apply locally *and* publish.
   *
   * Applying locally first keeps the picker instant — waiting on the round trip
   * would make every swatch click feel laggy — and `publish` is a no-op for
   * anyone who isn't a super admin.
   */
  return {
    background,
    accent,
    accentStyle,
    chartDepth,
    surface,
    paletteMode,
    setBackground: (value: PromoBackground) => {
      setBackground(value);
      publish({ background: value });
    },
    setAccent: (value: PromoAccent) => {
      setAccent(value);
      publish({ accent: value });
    },
    setAccentStyle: (value: PromoAccentStyle) => {
      setAccentStyle(value);
      publish({ accentStyle: value });
    },
    setChartDepth: (value: PromoChartDepth) => {
      setChartDepth(value);
      publish({ chartDepth: value });
    },
    setSurface: (value: PromoSurface) => {
      setSurface(value);
      publish({ surface: value });
    },
    setPaletteMode: (value: PromoPaletteMode) => {
      setPaletteMode(value);
      publish({ paletteMode: value });
    },
    theme,
    surfaceProps: { className: styles.pageSurface, "data-bg": background },
  };
}

/** Publishes the theme to nested charts, tables and badges. */
export function PromoThemeProvider({
  theme,
  children,
}: {
  theme: PromoTheme;
  children: ReactNode;
}) {
  return (
    <PromoThemeContext.Provider value={theme}>
      {children}
    </PromoThemeContext.Provider>
  );
}

/** Palette button + popover. Sits in the page header next to the other actions. */
export function AppearanceMenu({
  background,
  accent,
  accentStyle,
  chartDepth,
  surface,
  paletteMode,
  setBackground,
  setAccent,
  setAccentStyle,
  setChartDepth,
  setSurface,
  setPaletteMode,
}: Omit<Appearance, "surfaceProps" | "theme">) {
  // Live preview of what the current accent/style produces for a 5-series chart.
  const preview = useMemo(
    () => buildSeriesPalette(accent, 5, accentStyle, paletteMode),
    [accent, accentStyle, paletteMode],
  );
  // Mantine already persists the scheme and syncs the document attribute; this is
  // the first place in the console that actually calls it.
  const { colorScheme, setColorScheme } = useMantineColorScheme();

  return (
    <Popover position="bottom-end" withArrow shadow="md" width={264}>
      <Popover.Target>
        {/* Native title rather than a Mantine Tooltip: Popover.Target and
            Tooltip both want to own the child's ref, and nesting them is
            flaky. */}
        <ActionIcon
          variant="default"
          size="lg"
          aria-label="Appearance settings"
          title="Appearance"
        >
          <IconPalette size={17} stroke={1.7} />
        </ActionIcon>
      </Popover.Target>
      <Popover.Dropdown>
        <Stack gap="md">
          {/* The menu only renders for super admins, and their changes are saved
              console-wide — worth stating, since nothing else on screen implies a
              colour pick affects other people. */}
          <Text size="xs" c="dimmed">
            Applies to everyone in the console.
          </Text>
          <Stack gap={6}>
            <Text size="xs" fw={700} tt="uppercase" c="dimmed">
              Accent
            </Text>
            <Group gap={8}>
              {ACCENT_PRESETS.map((option) => {
                const selected =
                  accent.toLowerCase() === option.value.toLowerCase();
                return (
                  <Tooltip
                    key={option.value}
                    label={option.label}
                    withArrow
                    openDelay={200}
                  >
                    <motion.button
                      type="button"
                      aria-label={option.label}
                      aria-pressed={selected}
                      onClick={() => setAccent(option.value)}
                      whileTap={{ scale: 0.88 }}
                      whileHover={{ scale: 1.12 }}
                      className={styles.swatch}
                      style={{ background: option.value }}
                    >
                      {selected && <IconCheck size={13} stroke={3.4} />}
                    </motion.button>
                  </Tooltip>
                );
              })}
            </Group>

            {/* Free picker: the presets are shortcuts, not the whole choice.
                Everything downstream derives from the hue of whatever hex lands
                here, so an arbitrary colour works exactly like a preset. */}
            <ColorInput
              size="xs"
              format="hex"
              value={accent}
              onChange={setAccent}
              swatches={ACCENT_PRESETS.map((p) => p.value)}
              swatchesPerRow={8}
              placeholder="Any colour"
              aria-label="Custom accent colour"
            />
            <Group justify="space-between">
              <Text size="xs" c="dimmed">
                Any colour — palette derives from its hue.
              </Text>
              {accent.toLowerCase() !== DEFAULT_ACCENT && (
                <Text
                  size="xs"
                  c="dimmed"
                  style={{ cursor: "pointer", textDecoration: "underline" }}
                  onClick={() => setAccent(DEFAULT_ACCENT)}
                >
                  Reset
                </Text>
              )}
            </Group>
          </Stack>

          <Stack gap={6}>
            <Text size="xs" fw={700} tt="uppercase" c="dimmed">
              Palette
            </Text>
            <SegmentedControl
              size="xs"
              fullWidth
              value={paletteMode}
              onChange={(v) => setPaletteMode(v as PromoPaletteMode)}
              data={[
                { value: "multi", label: "Multi colour" },
                { value: "mono", label: "Mono" },
              ]}
            />
            <Text size="xs" c={paletteMode === "mono" ? "dimmed" : "dimmed"}>
              {paletteMode === "mono"
                ? "One hue, series separated by lightness — also colour-blind safe."
                : "Hues fan out from the accent, so each series is its own colour."}
            </Text>

            <Text size="xs" fw={700} tt="uppercase" c="dimmed" mt={4}>
              Chart fill
            </Text>
            <SegmentedControl
              size="xs"
              fullWidth
              value={accentStyle}
              onChange={(v) => setAccentStyle(v as PromoAccentStyle)}
              data={[
                { value: "solid", label: "Solid" },
                { value: "gradient", label: "Gradient" },
              ]}
            />
            {/* Shows the derived series colours, so the effect on charts,
                bars, tiles and badges is visible before committing. */}
            <Group gap={4} mt={2}>
              {preview.map((color, i) => (
                <span
                  key={i}
                  className={styles.paletteChip}
                  style={{
                    background: `linear-gradient(180deg, ${color.from}, ${color.to})`,
                  }}
                />
              ))}
            </Group>
            <Text size="xs" c="dimmed">
              Derived series colours — kept distinguishable, not all one hue.
            </Text>
          </Stack>

          <Stack gap={6}>
            <Text size="xs" fw={700} tt="uppercase" c="dimmed">
              Shading
            </Text>
            <SegmentedControl
              size="xs"
              fullWidth
              value={chartDepth}
              onChange={(v) => setChartDepth(v as PromoChartDepth)}
              data={[
                { value: "flat", label: "Flat" },
                { value: "glossy", label: "Glossy" },
                { value: "3d", label: "3D" },
              ]}
            />
            {chartDepth === "3d" ? (
              <Text size="xs" c="orange.7">
                Extruded bars stay accurate. The donut tilts, which makes near
                slices look larger than equal-value far ones.
              </Text>
            ) : (
              <Text size="xs" c="dimmed">
                {chartDepth === "glossy"
                  ? "Highlight and soft shadow."
                  : "No shading — plainest and fastest."}
              </Text>
            )}
          </Stack>

          {/* <Stack gap={6}>
            <Text size="xs" fw={700} tt="uppercase" c="dimmed">
              Colour scheme
            </Text>
            <SegmentedControl
              size="xs"
              fullWidth
              value={colorScheme}
              onChange={(v) => setColorScheme(v as "light" | "dark" | "auto")}
              data={[
                { value: "light", label: "Light" },
                { value: "dark", label: "Dark" },
                { value: "auto", label: "Auto" },
              ]}
            /> */}
          {/*
            Colour scheme is commented out above. Note if re-enabling: Mantine
            defines its dark colour variables on
            `:root[data-mantine-color-scheme]`, so the scheme cannot be scoped to
            one page — the control changes the whole console.
          */}
          <Stack gap={6}>
            <Text size="xs" fw={700} tt="uppercase" c="dimmed">
              Gloss &amp; shadow
            </Text>
            <SegmentedControl
              size="xs"
              fullWidth
              value={surface}
              onChange={(v) => setSurface(v as PromoSurface)}
              data={[
                { value: "flat", label: "None" },
                { value: "soft", label: "Soft" },
                { value: "deep", label: "Deep" },
              ]}
            />
            <Text size="xs" c="dimmed">
              {surface === "deep"
                ? "Pronounced lift and sheen on cards and tiles."
                : surface === "soft"
                  ? "Gentle lift and sheen."
                  : "No shadow or sheen."}
            </Text>

            <Text size="xs" fw={700} tt="uppercase" c="dimmed" mt={4}>
              Background
            </Text>
            <Stack gap={4}>
              {BACKGROUNDS.map((option) => {
                const selected = background === option.value;
                return (
                  <button
                    key={option.value}
                    type="button"
                    aria-pressed={selected}
                    onClick={() => setBackground(option.value)}
                    className={`${styles.bgOption} ${
                      selected ? styles.bgOptionActive : ""
                    }`}
                  >
                    <span
                      className={styles.bgPreview}
                      data-preview={option.value}
                      aria-hidden="true"
                    />
                    <span className={styles.bgOptionText}>
                      <Text size="sm" fw={600}>
                        {option.label}
                      </Text>
                      <Text size="xs" c="dimmed">
                        {option.hint}
                      </Text>
                    </span>
                    {selected && <IconCheck size={15} stroke={3} />}
                  </button>
                );
              })}
            </Stack>
          </Stack>
        </Stack>
      </Popover.Dropdown>
    </Popover>
  );
}
