import { TinyColor as Color, mostReadable, readability } from '@ctrl/tinycolor';
import type { Meta, StoryObj } from '@storybook/react';
import clsx from 'clsx';
import { Hsluv } from 'hsluv';
import { produce } from 'immer';
import { clamp, pick } from 'lodash-es';
import React, {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { FaAnchor, FaBezierCurve } from 'react-icons/fa';
import storageAvailable from 'storage-available';
import { twMerge } from 'tailwind-merge';

import Button from '@/components/button/Button';

import BezierCurveEditor, { BezierEditorChangeMode } from './BezierCurveEditor';
import {
  Point,
  PointType,
  applyPointTransformations,
  constrainedPoints,
  extractOpacityScale,
  generateColors,
  getX,
  getY,
  nonOverlappingPoints,
  scaledPoints,
} from './colorScaleUtils';

const meta: Meta = {
  title: 'utils/Colors',
};

export default meta;
type Story = StoryObj;

function getHsluv(c: Color) {
  const hsluv = new Hsluv();
  hsluv.hex = c.toHexString();
  hsluv.hexToRgb();
  hsluv.hexToHsluv();
  return {
    h: hsluv.hsluv_h,
    s: hsluv.hsluv_s,
    l: hsluv.hsluv_l,
  };
}

function getHsluvString(c: Color) {
  const { h, s, l } = getHsluv(c);
  return `hsluv(${[
    Math.round(h * 10) / 10,
    `${Math.round(s * 10) / 10}%`,
    `${Math.round(l * 10) / 10}%`,
  ].join(', ')})`;
}

function colorFromHsluv(h: number, s: number, l: number) {
  const hsluv = new Hsluv();
  hsluv.hsluv_h = h;
  hsluv.hsluv_s = s * 100;
  hsluv.hsluv_l = l * 100;
  hsluv.hsluvToHex();
  return new Color(hsluv.hex);
}

const LOCAL_STORAGE_STATE_KEY = 'color-scale-state';

const DEFAULT_GRAY = {
  type: 'hsl',
  a: [
    { x: 0, y: 240 },
    { x: 0.7, y: 240 },
    { x: 0.7, y: 240 },
    { x: 0.7, y: 320 },
    { x: 0.7, y: 400 },
    { x: 0.7, y: 400 },
    { x: 1, y: 400 },
  ],
  b: [
    { x: 0, y: 0.05 },
    { x: 0.6, y: 0.059 },
    { x: 0.608, y: 0 },
    { x: 0.7, y: 0 },
    { x: 0.959, y: 0 },
    { x: 0.837, y: 0.212 },
    { x: 1, y: 0.4 },
  ],
  c: [
    { x: 0, y: 0 },
    { x: 0.12, y: 0.19 },
    { x: 0.35, y: 0.25 },
    { x: 0.5, y: 0.5 },
    { x: 0.65, y: 0.75 },
    { x: 0.8, y: 0.8 },
    { x: 1, y: 1 },
  ],
};

const ColorScale: React.FC<
  React.HTMLAttributes<HTMLUListElement> & {
    itemClassName?: string;
    backgroundColor?: string | Color;
    contrastColors?: Array<string | Color>;
    colors: string[] | Color[];
    labels?: (string | React.ReactNode)[];
    itemProps?: (string | React.HTMLAttributes<HTMLLIElement> | undefined)[];
  }
> = (props) => {
  const {
    className,
    itemClassName,
    backgroundColor = '#000000',
    contrastColors,
    colors,
    labels,
    itemProps,
    ...restProps
  } = props;

  const includeAlpha = colors.some((color) =>
    typeof color === 'string'
      ? color.length > 7 && color.slice(-2).toLowerCase() !== 'ff'
      : color.getAlpha() < 1
  );

  return (
    <ul
      className={twMerge(
        'flex flex-col items-stretch justify-stretch',
        className
      )}
      {...restProps}
    >
      {colors.map((c, i) => {
        const color = typeof c === 'string' ? new Color(c) : c;
        return (
          <li
            key={i}
            {...(typeof itemProps?.[i] === 'object'
              ? itemProps?.[i]
              : undefined)}
            className={twMerge(
              'flex flex-row items-center justify-start gap-1 px-4 py-1',
              itemClassName,
              typeof itemProps?.[i] === 'string'
                ? itemProps?.[i]
                : itemProps?.[i]?.className
            )}
            style={{
              backgroundColor: color.toHex8String(),
              color: mostReadable(color.onBackground(backgroundColor), [
                '#fff',
                '#000',
              ])?.toHex8String(),
              ...(typeof itemProps?.[i] === 'object'
                ? itemProps?.[i]?.style
                : undefined),
            }}
            data-color={JSON.stringify(color.originalInput)}
          >
            <div className='text-sm leading-tight'>
              {labels?.[i] == null ? null : (
                <p className='font-bold'>{labels?.[i]}</p>
              )}
              <p className='font-mono text-xs leading-tight tracking-[-.1em]'>
                {typeof c === 'string'
                  ? c
                  : includeAlpha
                    ? color.toHex8String()
                    : color.toHexString()}
              </p>
            </div>
            <div className='w-32 pl-3 text-xs'>
              <p>{color.toRgbString()}</p>
              <p>{color.toHslString()}</p>
            </div>
            <div className='w-40 pl-3 text-xs'>
              <p>
                Brightness{' '}
                {Math.round((1000 / 255) * color.getBrightness()) / 10}%
              </p>
              <p>{getHsluvString(color)}</p>
            </div>
            {!contrastColors?.length ? null : (
              <>
                <div className='flex-1' />
                {contrastColors.map((c) => {
                  const contrastColor =
                    typeof c === 'string' ? new Color(c) : c;
                  const contrastRatio = readability(
                    contrastColor,
                    color.onBackground(backgroundColor)
                  );
                  const contrastLevel =
                    contrastRatio >= 7
                      ? '✅'
                      : contrastRatio >= 4.5
                        ? '🔷'
                        : '❌';
                  return (
                    <div key={contrastColor.toHex8String()}>
                      <div
                        className='flex h-6 min-w-10 flex-row items-center justify-start gap-1 rounded-full p-2 text-xs'
                        style={{
                          backgroundColor: contrastColor.toHex8String(),
                          color: mostReadable(contrastColor, [
                            '#fff',
                            '#000',
                          ])?.toHex8String(),
                        }}
                      >
                        {contrastLevel == null ? null : (
                          <p className='text-[10px]'>{contrastLevel}</p>
                        )}
                        <p>{Math.round(contrastRatio * 10) / 10}:1</p>
                      </div>
                    </div>
                  );
                })}
              </>
            )}
          </li>
        );
      })}
    </ul>
  );
};

type ColorConfig = {
  aName: string;
  axMin: number;
  axMax: number;
  axScale: number;
  axStep: number;
  axRound: number;
  ayMin: number;
  ayMax: number;
  ayScale: number;
  ayStep: number;
  ayRound: number;
  a?: Point[];
  bName: string;
  bxMin: number;
  bxMax: number;
  bxScale: number;
  bxStep: number;
  bxRound: number;
  byMin: number;
  byMax: number;
  byScale: number;
  byStep: number;
  byRound: number;
  b?: Point[];
  cName: string;
  cxMin: number;
  cxMax: number;
  cxScale: number;
  cxStep: number;
  cxRound: number;
  cyMin: number;
  cyMax: number;
  cyScale: number;
  cyStep: number;
  cyRound: number;
  c?: Point[];
  toColor: (a: number, b: number, c: number) => Color;
};

type ColorConfigState = Partial<
  Pick<ColorConfig, 'a' | 'b' | 'c'> & { type: string; backgroundColor: string }
>;

const CLASSNAME_TITLE = 'font-bold';
const CLASSNAME_GROUP =
  'flex flex-row items-center justify-center gap-2 text-foreground-primary';
const CLASSNAME_LABEL =
  'flex flex-row items-center justify-center gap-1 text-xs text-foreground-secondary';
const CLASSNAME_INPUT =
  'block w-16 rounded-sm px-1 text-sm text-foreground-primary';

const COLOR_TYPE_CONFIG: Record<string, ColorConfig> = {
  hsluv: {
    aName: 'Hue',
    axMin: 0,
    axMax: 1,
    axScale: 1,
    axStep: 0.01,
    axRound: 1000,
    ayMin: -Infinity,
    ayMax: Infinity,
    ayScale: 720,
    ayStep: 1,
    ayRound: 1,
    a: [
      { x: 0, y: 60 },
      { x: 0.2, y: 60 },
      { x: 0.3, y: 60 },
      { x: 0.5, y: 60 },
      { x: 0.7, y: 60 },
      { x: 0.8, y: 60 },
      { x: 1, y: 60 },
    ],
    bName: 'Saturation',
    bxMin: 0,
    bxMax: 1,
    bxScale: 1,
    bxStep: 0.01,
    bxRound: 1000,
    byMin: 0,
    byMax: 1,
    byScale: 1,
    byStep: 0.01,
    byRound: 1000,
    b: [
      { x: 0, y: 1 },
      { x: 0.1, y: 1 },
      { x: 0.45, y: 1 },
      { x: 0.5, y: 1 },
      { x: 0.6, y: 1 },
      { x: 0.9, y: 1 },
      { x: 1, y: 1 },
    ],
    cName: 'Lightness',
    cxMin: 0,
    cxMax: 1,
    cxScale: 1,
    cxStep: 0.01,
    cxRound: 1000,
    cyMin: 0,
    cyMax: 1,
    cyScale: 1,
    cyStep: 0.01,
    cyRound: 1000,
    c: [
      { x: 0, y: 0 },
      { x: 0.25, y: 0.25 },
      { x: 0.25, y: 0.25 },
      { x: 0.5, y: 0.5 },
      { x: 0.75, y: 0.75 },
      { x: 0.75, y: 0.75 },
      { x: 1, y: 1 },
    ],
    toColor(h, s, l) {
      return colorFromHsluv(h, s, l);
    },
  },
  hsl: {
    aName: 'Hue',
    axMin: 0,
    axMax: 1,
    axScale: 1,
    axStep: 0.01,
    axRound: 1000,
    ayMin: -Infinity,
    ayMax: Infinity,
    ayScale: 720,
    ayStep: 1,
    ayRound: 1,
    a: [
      { x: 0, y: 60 },
      { x: 0.2, y: 60 },
      { x: 0.3, y: 60 },
      { x: 0.5, y: 60 },
      { x: 0.7, y: 60 },
      { x: 0.8, y: 60 },
      { x: 1, y: 60 },
    ],
    bName: 'Saturation',
    bxMin: 0,
    bxMax: 1,
    bxScale: 1,
    bxStep: 0.01,
    bxRound: 1000,
    byMin: 0,
    byMax: 1,
    byScale: 1,
    byStep: 0.01,
    byRound: 1000,
    b: [
      { x: 0, y: 1 },
      { x: 0.1, y: 1 },
      { x: 0.45, y: 1 },
      { x: 0.5, y: 1 },
      { x: 0.6, y: 1 },
      { x: 0.9, y: 1 },
      { x: 1, y: 1 },
    ],
    cName: 'Lightness',
    cxMin: 0,
    cxMax: 1,
    cxScale: 1,
    cxStep: 0.01,
    cxRound: 1000,
    cyMin: 0,
    cyMax: 1,
    cyScale: 1,
    cyStep: 0.01,
    cyRound: 1000,
    c: [
      { x: 0, y: 0 },
      { x: 0.25, y: 0.25 },
      { x: 0.25, y: 0.25 },
      { x: 0.5, y: 0.5 },
      { x: 0.75, y: 0.75 },
      { x: 0.75, y: 0.75 },
      { x: 1, y: 1 },
    ],
    toColor(h, s, l) {
      return new Color({ h, s, l });
    },
  },
  rgb: {
    aName: 'Red',
    axMin: 0,
    axMax: 1,
    axScale: 1,
    axStep: 0.01,
    axRound: 1000,
    ayMin: 0,
    ayMax: 1,
    ayScale: 1,
    ayStep: 0.01,
    ayRound: 1000,
    bName: 'Green',
    bxMin: 0,
    bxMax: 1,
    bxScale: 1,
    bxStep: 0.01,
    bxRound: 1000,
    byMin: 0,
    byMax: 1,
    byScale: 1,
    byStep: 0.01,
    byRound: 1000,
    cName: 'Blue',
    cxMin: 0,
    cxMax: 1,
    cxScale: 1,
    cxStep: 0.01,
    cxRound: 1000,
    cyMin: 0,
    cyMax: 1,
    cyScale: 1,
    cyStep: 0.01,
    cyRound: 1000,
    toColor(r, g, b) {
      return new Color({ r, g, b });
    },
  },
};

function getDefaults(type: keyof typeof COLOR_TYPE_CONFIG) {
  return pick(COLOR_TYPE_CONFIG[type], ['a', 'b', 'c'] satisfies Array<
    keyof ColorConfigState
  >);
}

const BezierInputs: React.FC<
  Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> & {
    titleClassName?: string;
    groupClassName?: string;
    labelClassName?: string;
    inputClassName?: string;
    xMin?: number;
    yMin?: number;
    xMax?: number;
    yMax?: number;
    xStep?: number;
    yStep?: number;
    points: Point[];
    onChange: (points: Point[]) => void;
  }
> = (props) => {
  const {
    children,
    className,
    titleClassName = CLASSNAME_TITLE,
    groupClassName = CLASSNAME_GROUP,
    labelClassName = CLASSNAME_LABEL,
    inputClassName = CLASSNAME_INPUT,
    xMin,
    yMin,
    xMax,
    yMax,
    xStep = xMax == null || xMax > 1 ? 1 : 0.01,
    yStep = xMax == null || xMax > 1 ? 1 : 0.01,
    points,
    onChange,
    ...restProps
  } = props;

  const pointsRef = useRef(points);
  useEffect(() => {
    pointsRef.current = points;
  }, [points]);

  const onChangePoint = useCallback(
    (point: Point, i: number) => {
      const nextPoints = [...pointsRef.current];
      nextPoints[i] = point;
      onChange(nextPoints);
    },
    [onChange]
  );

  return (
    <div
      {...restProps}
      className={twMerge(
        'flex flex-col items-start justify-start gap-0.5',
        className
      )}
    >
      {children && <div className={titleClassName}>{children}</div>}
      {points.map((point, i) => (
        <div className={groupClassName} key={`point-${i}`}>
          <div>
            {i % 3 === 0 ? (
              <FaAnchor title={`Anchor ${i}`} />
            ) : (
              <FaBezierCurve title={`Control ${i}`} />
            )}
          </div>
          <label className={labelClassName}>
            <span>x</span>
            <input
              className={inputClassName}
              type='number'
              min={xMin}
              max={xMax}
              step={xStep}
              placeholder={`x: ${xMin ?? 0}`}
              value={point.x}
              onChange={(e, value = parseFloat(e.currentTarget.value)) =>
                onChangePoint({ x: value, y: point.y }, i)
              }
              onBlur={(e, value = parseFloat(e.currentTarget.value)) =>
                onChangePoint(
                  { x: Math.round(value * 1000) / 1000, y: point.y },
                  i
                )
              }
            />
          </label>
          <label className={labelClassName}>
            <span>y</span>
            <input
              className={inputClassName}
              type='number'
              min={yMin}
              max={yMax}
              step={yStep}
              placeholder={`y: ${yMin ?? 0}`}
              value={point.y}
              onChange={(e, value = parseFloat(e.currentTarget.value)) =>
                onChangePoint({ x: point.x, y: value }, i)
              }
              onBlur={(e, value = parseFloat(e.currentTarget.value)) =>
                onChangePoint(
                  { x: point.x, y: Math.round(value * 1000) / 1000 },
                  i
                )
              }
            />
          </label>
        </div>
      ))}
    </div>
  );
};

const COLOR_SCALE_STOPS = new Array(21)
  .fill(0)
  .map((_, i, values) => 1000 * (i / (values.length - 1)));
// const COLOR_SCALE_STOPS = [
//   0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
// ];
// const COLOR_SCALE_STOPS = [
//   0, 50, 100, 200, 300, 350, 400, 450, 500, 600, 700, 800, 850, 900, 950, 1000,
// ];

function extractStopsFromColorNames(names: string[]) {
  const values = names.map((name) => {
    const [, value] = name.match(/(\d+)$/) || [];
    return value == null ? undefined : parseInt(value, 10);
  });
  const start = values.find((value) => value != null) || 0;
  const end = values.findLast((value) => value != null) || 0;
  const [min, max] = start < end ? [0, 1000] : [1000, 0];

  return values.map((value, i) =>
    value != null ? value : i < values.length / 2 ? min : max
  );
}

function getItemProps(
  stops: number[]
): Array<React.HTMLAttributes<HTMLLIElement>> {
  const baseInterval = stops.reduce(
    (acc, value, i, all) =>
      i > 0 ? Math.min(acc, Math.abs(value - all[i - 1])) : acc,
    Infinity
  );
  const intervals = stops.map((value, i, values) =>
    i === 0
      ? Math.abs(values[i + 1] - value)
      : i === values.length - 1
        ? Math.abs(value - values[i - 1])
        : 0.5 * Math.abs(values[i + 1] - values[i - 1])
  );

  return stops.map((_value, i) => {
    const interval = intervals[i];
    return {
      className: 'basis-[calc(3rem*var(--color-block-size,1))]',
      style: {
        '--color-block-size': interval / baseInterval,
      } as React.CSSProperties,
      onDoubleClick: (e) => {
        if (e.currentTarget.dataset.color) {
          const color = new Color(JSON.parse(e.currentTarget.dataset.color));
          navigator.clipboard.writeText(color.toHexString());
        }
      },
    };
  });
}

const LEGACY_GRAY_SCALE = [
  { color: '#000000', name: 'Black' },
  { color: '#0e0808', name: 'Gray 50' },
  { color: '#252020', name: 'Gray 100' },
  { color: '#363030', name: 'Gray 200' },
  { color: '#4a4545', name: 'Gray 300' },
  { color: '#565252', name: 'Gray 350' },
  { color: '#676363', name: 'Gray 400' },
  { color: '#726e6c', name: 'Gray 450' },
  { color: '#8b8785', name: 'Gray 500' },
  { color: '#bab4b1', name: 'Gray 600' },
  { color: '#d1cbc7', name: 'Gray 700' },
  { color: '#e8e1dd', name: 'Gray 800' },
  { color: '#efeae7', name: 'Gray 850' },
  { color: '#f5f0ed', name: 'Gray 900' },
  { color: '#faf7f5', name: 'Gray 950' },
  { color: '#ffffff', name: 'White' },
];
const LEGACY_GRAY_SCALE_STOPS = extractStopsFromColorNames(
  LEGACY_GRAY_SCALE.map(({ name }) => name)
);

const ColorScaleTool: React.FC<React.HTMLAttributes<HTMLDivElement>> = (
  props
) => {
  const { className, ...restProps } = props;

  const [showOpacityScale, setShowOpacityScale] = useState(false);

  const [state, setState] = useState(() => {
    let persistedState: ColorConfigState = {};
    if (storageAvailable('localStorage')) {
      try {
        persistedState = JSON.parse(
          localStorage.getItem(LOCAL_STORAGE_STATE_KEY) || '{}'
        );
      } catch (e) {
        // No local storage
      }
    }
    return {
      backgroundColor: '#000000',
      type: 'hsl',
      a: [
        { x: 0, y: 0 },
        { x: 0.25, y: 0.25 },
        { x: 0.75, y: 0.75 },
        { x: 1, y: 1 },
      ],
      b: [
        { x: 0, y: 0 },
        { x: 0.25, y: 0.25 },
        { x: 0.75, y: 0.75 },
        { x: 1, y: 1 },
      ],
      c: [
        { x: 0, y: 0 },
        { x: 0.25, y: 0.25 },
        { x: 0.75, y: 0.75 },
        { x: 1, y: 1 },
      ],
      ...getDefaults(persistedState?.type ?? 'hsl'),
      ...persistedState,
    } satisfies ColorConfigState;
  });

  const updateState = useCallback((updates: Partial<typeof state>) => {
    setState((prevState) =>
      produce(prevState, (nextState) => {
        Object.assign(nextState, updates);
      })
    );
  }, []);

  useEffect(() => {
    if (storageAvailable('localStorage')) {
      localStorage.setItem(LOCAL_STORAGE_STATE_KEY, JSON.stringify(state));
    }
  }, [state]);

  const colorTypeConfig =
    COLOR_TYPE_CONFIG[state.type || ''] || COLOR_TYPE_CONFIG.rgb;

  const getParametricColor = useCallback(
    (t: number) => {
      const a = getY(state.a, t) ?? 0;
      const b = getY(state.b, t) ?? 0;
      const c = getY(state.c, t) ?? 0;
      return colorTypeConfig.toColor(a, b, c);
    },
    [state, colorTypeConfig]
  );

  const [colors, labels, itemProps, opacity, opacityLabels, opacityItemProps] =
    useMemo(() => {
      const nextColors = generateColors(
        COLOR_SCALE_STOPS.map((v) => v / 1000),
        getParametricColor
      );
      const nextOpacity = extractOpacityScale(nextColors);
      const nextItemProps = getItemProps(COLOR_SCALE_STOPS);
      return [
        nextColors,
        COLOR_SCALE_STOPS,
        nextItemProps,
        nextOpacity,
        nextOpacity.map((color) => `${Math.round(color.getAlpha() * 100)}%`),
        nextItemProps,
      ];
    }, [getParametricColor]);

  const getChangeHandler = useCallback<
    (
      key: 'a' | 'b' | 'c',
      config: {
        xRound?: number;
        yRound?: number;
        xScale?: number;
        yScale?: number;
        xMin?: number;
        xMax?: number;
        yMin?: number;
        yMax?: number;
      }
    ) => React.ComponentProps<typeof BezierCurveEditor>['onChange']
  >(
    (key, config = {}) =>
      (points, options) => {
        const { prevPoints: lastPoints, index, changeMode } = options;
        updateState({
          [key]: applyPointTransformations(points, [
            changeMode & BezierEditorChangeMode.ConstrainMinMax
              ? (prevPoints) =>
                  produce(prevPoints, (nextPoints) => {
                    for (let i = 0; i < nextPoints.length; i++) {
                      const point = nextPoints[i];
                      if (i % 3 === PointType.Anchor) {
                        point.x = clamp(point.x, 0, 1);
                        point.y = clamp(point.y, 0, 1);
                      }
                      // Pin start/end points to the sides
                      if (i === 0) point.x = 0;
                      if (i === nextPoints.length - 1) point.x = 1;
                    }
                  })
              : undefined,
            changeMode & BezierEditorChangeMode.PreventOverlap
              ? (prevPoints) => nonOverlappingPoints(prevPoints, index)
              : undefined,
            changeMode & BezierEditorChangeMode.AnchorControlSync
              ? (prevPoints) =>
                  produce(prevPoints, (nextPoints) => {
                    if (index % 3 === PointType.Anchor) {
                      const point = nextPoints[index];
                      const incomingControl =
                        index - 1 >= 0 ? nextPoints[index - 1] : undefined;
                      const outgoingControl =
                        index - 1 < nextPoints.length
                          ? nextPoints[index + 1]
                          : undefined;
                      const dx = point.x - lastPoints[index].x;
                      const dy = point.y - lastPoints[index].y;
                      if (incomingControl) {
                        incomingControl.x += dx;
                        incomingControl.y += dy;
                      }
                      if (outgoingControl) {
                        outgoingControl.x += dx;
                        outgoingControl.y += dy;
                      }
                    }
                  })
              : undefined,
            changeMode & BezierEditorChangeMode.ControlSymmetric
              ? (prevPoints) =>
                  produce(prevPoints, (nextPoints) => {
                    const point = nextPoints[index];
                    const pointType = index % 3;
                    let otherPoint: Point | undefined = undefined;
                    let anchorPoint: Point | undefined = undefined;
                    if (
                      pointType === PointType.ControlOutbound &&
                      index - 2 >= 0
                    ) {
                      anchorPoint = nextPoints[index - 1];
                      otherPoint = nextPoints[index - 2];
                    } else if (
                      pointType === PointType.ControlInbound &&
                      index + 2 < nextPoints.length
                    ) {
                      anchorPoint = nextPoints[index + 1];
                      otherPoint = nextPoints[index + 2];
                    }
                    if (otherPoint && anchorPoint) {
                      const dx = anchorPoint.x - point.x;
                      const dy = anchorPoint.y - point.y;
                      otherPoint.x = anchorPoint.x + dx;
                      otherPoint.y = anchorPoint.y + dy;
                    }
                  })
              : undefined,
            (prevPoints) =>
              scaledPoints(
                prevPoints,
                config.xScale ?? 1,
                config.yScale ?? 1,
                config.xRound,
                config.yRound
              ),
            changeMode & BezierEditorChangeMode.ConstrainMinMax
              ? (prevPoints) =>
                  constrainedPoints(
                    prevPoints,
                    config.xMin,
                    config.xMax,
                    config.yMin,
                    config.yMax
                  )
              : undefined,
          ]),
        });
      },
    [updateState]
  );

  const curveChangeHandler = useMemo(() => {
    return {
      a: getChangeHandler('a', {
        xMin: colorTypeConfig.axMin,
        xMax: colorTypeConfig.axMax,
        xScale: colorTypeConfig.axScale,
        xRound: colorTypeConfig.axRound,
        yMin: colorTypeConfig.ayMin,
        yMax: colorTypeConfig.ayMax,
        yScale: colorTypeConfig.ayScale,
        yRound: colorTypeConfig.ayRound,
      }),
      b: getChangeHandler('b', {
        xMin: colorTypeConfig.bxMin,
        xMax: colorTypeConfig.bxMax,
        xScale: colorTypeConfig.bxScale,
        xRound: colorTypeConfig.bxRound,
        yMin: colorTypeConfig.byMin,
        yMax: colorTypeConfig.byMax,
        yScale: colorTypeConfig.byScale,
        yRound: colorTypeConfig.byRound,
      }),
      c: getChangeHandler('c', {
        xMin: colorTypeConfig.cxMin,
        xMax: colorTypeConfig.cxMax,
        xScale: colorTypeConfig.cxScale,
        xRound: colorTypeConfig.cxRound,
        yMin: colorTypeConfig.cyMin,
        yMax: colorTypeConfig.cyMax,
        yScale: colorTypeConfig.cyScale,
        yRound: colorTypeConfig.cyRound,
      }),
    };
  }, [
    getChangeHandler,
    colorTypeConfig.axMin,
    colorTypeConfig.axMax,
    colorTypeConfig.axScale,
    colorTypeConfig.axRound,
    colorTypeConfig.ayMin,
    colorTypeConfig.ayMax,
    colorTypeConfig.ayScale,
    colorTypeConfig.ayRound,
    colorTypeConfig.bxMin,
    colorTypeConfig.bxMax,
    colorTypeConfig.bxScale,
    colorTypeConfig.bxRound,
    colorTypeConfig.byMin,
    colorTypeConfig.byMax,
    colorTypeConfig.byScale,
    colorTypeConfig.byRound,
    colorTypeConfig.cxMin,
    colorTypeConfig.cxMax,
    colorTypeConfig.cxScale,
    colorTypeConfig.cxRound,
    colorTypeConfig.cyMin,
    colorTypeConfig.cyMax,
    colorTypeConfig.cyScale,
    colorTypeConfig.cyRound,
  ]);

  const handleImportColorKeyDown = useCallback<
    React.KeyboardEventHandler<HTMLInputElement>
  >(
    (e) => {
      if (e.key === 'Enter') {
        const color = new Color(e.currentTarget.value);
        let { h, s, l } = color.toHsl();
        const hsluv = new Hsluv();
        hsluv.hex = color.toHexString();
        hsluv.hexToHsluv();
        if (state.type === 'hsluv') {
          h = hsluv.hsluv_h;
          s = hsluv.hsluv_s / 100;
          l = hsluv.hsluv_l / 100;
          hsluv.hsluvToHex();
        }
        const nudgeMatchingPoint = !e.shiftKey;
        const resetLightness = e.altKey;
        setState((prevState) => {
          const initialState = resetLightness
            ? {
                ...prevState,
                c: DEFAULT_GRAY.c,
              }
            : prevState;
          return produce(initialState, (nextState) => {
            // Find x-position of closest lightness
            const closestX = getX(initialState.c, l) ?? 0.5;
            const colorValue = Math.round(closestX * 1000);
            const scaleValue50 = Math.round(colorValue / 50) * 50;
            const scaleValue100 = Math.round(colorValue / 100) * 100;
            const centerOffsetX = nudgeMatchingPoint
              ? (closestX * 1000 - scaleValue100) / 1000
              : 0;
            const centerX = closestX - centerOffsetX;
            window.alert(
              [
                `${color.toHexString()}: ${color.toHslString()}\n`,
                scaleValue50 === colorValue
                  ? `✅ Scale value is ${scaleValue50}`
                  : centerOffsetX
                    ? `ℹ️ Nudging scale value ${colorValue} to ${scaleValue100}`
                    : `⚠️ Closeset scale value is ${scaleValue50} (actual: ${colorValue})`,
                resetLightness
                  ? 'Resetting lightness'
                  : 'Using previous lightness values',
              ]
                .filter((v) => v)
                .join('\n')
            );
            nextState.a = initialState.a.map((_point, i) => ({
              x:
                i <= 3
                  ? (centerX * i) / 3
                  : centerX + ((1 - centerX) * (i - 3)) / 3,
              y: Math.round(h),
            }));
            nextState.b = initialState.b.map((_point, i) => ({
              x:
                i <= 3
                  ? (centerX * i) / 3
                  : centerX + ((1 - centerX) * (i - 3)) / 3,
              y: Math.round(s * 1000) / 1000,
            }));
            // Move the lightness for the closest point
            nextState.c = initialState.c.map((point, i) => ({
              x: Math.abs(i - 3) < 2 ? point.x - centerOffsetX : point.x,
              y: point.y,
            }));
          });
        });
      }
    },
    [state.type]
  );

  const handleImportExportFocus = useCallback<
    React.FocusEventHandler<HTMLTextAreaElement>
  >((e) => {
    e.currentTarget.select();
  }, []);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    if (textareaRef.current) {
      textareaRef.current.value = JSON.stringify(state);
    }
  }, [state]);
  const handleImportExportInput = useCallback<
    React.KeyboardEventHandler<HTMLTextAreaElement>
  >(
    (e) => {
      const value = e.currentTarget.value;
      try {
        const nextState = pick(JSON.parse(value), ['type', 'a', 'b', 'c']);
        updateState(nextState);
      } catch (err) {
        e.preventDefault();
      }
    },
    [updateState]
  );
  const handleImportExportPaste = useCallback<
    React.ClipboardEventHandler<HTMLTextAreaElement>
  >(
    (e) => {
      const value = e.clipboardData.getData('text');
      try {
        const nextState = pick(JSON.parse(value), ['type', 'a', 'b', 'c']);
        updateState(nextState);
        e.preventDefault();
      } catch (e) {}
    },
    [updateState]
  );

  return (
    <div
      className={twMerge('fixed inset-0 flex flex-row gap-2', className)}
      {...restProps}
    >
      <div className='flex flex-col gap-4 overflow-auto p-4'>
        <div>{state.type}</div>
        <div className='flex flex-row gap-2'>
          <BezierInputs
            xMin={colorTypeConfig.axMin ?? 0}
            xMax={colorTypeConfig.axMax ?? 1}
            xStep={colorTypeConfig.axStep ?? 0.01}
            yMin={colorTypeConfig.ayMin ?? 0}
            yMax={colorTypeConfig.ayMax ?? 1}
            yStep={colorTypeConfig.ayStep ?? 0.01}
            points={state.a}
            onChange={(a) => {
              updateState({ a });
            }}
          >
            {colorTypeConfig.aName}
          </BezierInputs>
          <BezierCurveEditor
            className='w-[180px]'
            scaleX={170}
            viewBoxPaddingX={5}
            points={scaledPoints(
              state.a,
              1 / (colorTypeConfig.axScale ?? 1),
              1 / (colorTypeConfig.ayScale ?? 1)
            )}
            onChange={curveChangeHandler.a}
          />
        </div>
        <div className='flex flex-row gap-2'>
          <BezierInputs
            xMin={colorTypeConfig.bxMin ?? 0}
            xMax={colorTypeConfig.bxMax ?? 1}
            xStep={colorTypeConfig.bxStep ?? 0.01}
            yMin={colorTypeConfig.byMin ?? 0}
            yMax={colorTypeConfig.byMax ?? 1}
            yStep={colorTypeConfig.byStep ?? 0.01}
            points={state.b}
            onChange={(b) => {
              updateState({ b });
            }}
          >
            {colorTypeConfig.bName}
          </BezierInputs>
          <BezierCurveEditor
            className='w-[180px]'
            scaleX={170}
            viewBoxPaddingX={5}
            points={scaledPoints(
              state.b,
              1 / (colorTypeConfig.bxScale ?? 1),
              1 / (colorTypeConfig.byScale ?? 1)
            )}
            onChange={curveChangeHandler.b}
          />
        </div>
        <div className='flex flex-row gap-2'>
          <BezierInputs
            xMin={colorTypeConfig.cxMin ?? 0}
            xMax={colorTypeConfig.cxMax ?? 1}
            xStep={colorTypeConfig.cxStep ?? 0.01}
            yMin={colorTypeConfig.cyMin ?? 0}
            yMax={colorTypeConfig.cyMax ?? 1}
            yStep={colorTypeConfig.cyStep ?? 0.01}
            points={state.c}
            onChange={(c) => {
              updateState({ c });
            }}
          >
            {colorTypeConfig.cName}
          </BezierInputs>
          <BezierCurveEditor
            className='w-[180px]'
            scaleX={170}
            viewBoxPaddingX={5}
            points={scaledPoints(
              state.c,
              1 / (colorTypeConfig.cxScale ?? 1),
              1 / (colorTypeConfig.cyScale ?? 1)
            )}
            onChange={curveChangeHandler.c}
          />
        </div>
        <div className='flex flex-row items-center gap-2'>
          <div>
            <Button onClick={() => updateState(DEFAULT_GRAY)}>
              Reset to grays
            </Button>
          </div>
          <div>
            <Button
              onClick={() => setShowOpacityScale((prevValue) => !prevValue)}
            >
              Opacity Scale
            </Button>
          </div>
          <div>
            <Button
              onClick={() => {
                navigator.clipboard.writeText(
                  colors
                    .map((value, i) => `${i * 50}: ${value.toHexString()}`)
                    .join('\n') + '\n'
                );
              }}
            >
              Copy all as hex
            </Button>
          </div>
        </div>
        <label className='flex flex-row items-center justify-stretch gap-2'>
          <div className={twMerge(CLASSNAME_TITLE, 'flex-1')}>
            Background color
          </div>
          <div className='basis-20'>
            <input
              className={twMerge(CLASSNAME_INPUT, 'w-full text-right')}
              type='text'
              placeholder='#rrggbb'
              value={state.backgroundColor}
              onChange={(e) =>
                updateState({ backgroundColor: e.currentTarget.value })
              }
            />
          </div>
        </label>
        <label className='flex flex-row items-center justify-stretch gap-2'>
          <div className={twMerge(CLASSNAME_TITLE, 'flex-1')}>Import color</div>
          <div className='basis-20'>
            <input
              className={twMerge(CLASSNAME_INPUT, 'w-full text-right')}
              type='text'
              placeholder='#rrggbb'
              onKeyDown={handleImportColorKeyDown}
            />
          </div>
        </label>
        <div className='flex flex-col items-start justify-start gap-0.5'>
          <div className={CLASSNAME_TITLE}>Import/export settings</div>
          <div className='w-full'>
            <textarea
              ref={textareaRef}
              className={twMerge(CLASSNAME_INPUT, 'h-40 w-full')}
              placeholder='{ ... }'
              onFocus={handleImportExportFocus}
              onInput={handleImportExportInput}
              onPaste={handleImportExportPaste}
            />
          </div>
        </div>
      </div>
      <div className='min-h-full flex-1 overflow-y-auto'>
        <div className='flex min-h-full flex-row items-stretch justify-stretch'>
          <ColorScale
            className='min-h-full flex-1'
            itemClassName='grow'
            colors={colors}
            contrastColors={['#f7f4ef', '#1c1c1f']}
            labels={labels}
            itemProps={itemProps}
          />
          <ColorScale
            className={clsx('min-h-full flex-1', { hidden: !showOpacityScale })}
            style={{ backgroundColor: state.backgroundColor }}
            itemClassName='grow'
            colors={opacity}
            backgroundColor={state.backgroundColor}
            labels={opacityLabels}
            itemProps={opacityItemProps}
          />
          <ColorScale
            className='hidden min-h-full flex-1'
            itemClassName='grow'
            colors={LEGACY_GRAY_SCALE.map(({ color }) => color)}
            labels={LEGACY_GRAY_SCALE.map(({ name }) => name)}
            itemProps={getItemProps(LEGACY_GRAY_SCALE_STOPS)}
          />
        </div>
      </div>
    </div>
  );
};

export const ColorScaleGenerator: Story = {
  render: () => <ColorScaleTool />,
};
