import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
import color from 'color';
import { clamp } from '../../utilities/math';

/**
 * @since 2.0
 * @status stable
 *
 * @part base - The component's base wrapper.
 * @part trigger - The color picker's dropdown trigger.
 * @part copy-button - The copy button.
 * @part swatches - The container that holds swatches.
 * @part swatch - Each individual swatch.
 * @part grid - The color grid.
 * @part grid-handle - The color grid's handle.
 * @part hue-slider - The hue slider.
 * @part opacity-slider - The opacity slider.
 * @part slider - Hue and opacity sliders.
 * @part slider-handle - Hue and opacity slider handles.
 * @part preview - The preview color.
 * @part input - The text input.
 * @part copy-button - The copy button.
 */

@Component({
  tag: 'sl-color-picker',
  styleUrl: 'color-picker.scss',
  shadow: true
})
export class ColorPicker {
  bypassValueParse = false;
  copyButton: HTMLSlButtonElement;
  dropdown: HTMLSlDropdownElement;
  lastValueEmitted: string;
  menu: HTMLElement;
  input: HTMLSlInputElement;
  trigger: HTMLButtonElement;

  @Element() host: HTMLSlColorPickerElement;

  @State() inputValue = '';
  @State() hue = 0;
  @State() saturation = 100;
  @State() lightness = 100;
  @State() alpha = 100;
  @State() showCopyCheckmark = false;

  /** The current color. */
  @Prop({ mutable: true, reflect: true }) value = '#ffffff';

  /**
   * The format to use for the display value. If opacity is enabled, these will translate to HEXA, RGBA, and HSLA
   * respectively. The color picker will always accept user input in any format (including CSS color names) and convert
   * it to the desired format.
   */
  @Prop() format: 'hex' | 'rgb' | 'hsl' = 'hex';

  /** Set to true to render the color picker inline rather than inside a dropdown. */
  @Prop() inline = false;

  /** Determines the size of the color picker's trigger. This has no effect on inline color pickers. */
  @Prop() size: 'small' | 'medium' | 'large' = 'medium';

  /** The input's name attribute. */
  @Prop({ reflect: true }) name = '';

  /** Set to true to disable the color picker. */
  @Prop() disabled = false;

  /**
   * This will be true when the control is in an invalid state. Validity is determined by the `setCustomValidity()`
   * method using the browser's constraint validation API.
   */
  @Prop({ mutable: true, reflect: true }) invalid = false;

  /**
   * Enable this option to prevent the panel from being clipped when the component is placed inside a container with
   * `overflow: auto|scroll`.
   */
  @Prop() hoist = false;

  /** Whether to show the opacity slider. */
  @Prop() opacity = false;

  /** By default, the value will be set in lowercase. Set this to true to set it in uppercase instead. */
  @Prop() uppercase = false;

  /**
   * An array of predefined color swatches to display. Can include any format the color picker can parse, including
   * HEX(A), RGB(A), HSL(A), and CSS color names.
   */
  @Prop() swatches = [
    '#d0021b',
    '#f5a623',
    '#f8e71c',
    '#8b572a',
    '#7ed321',
    '#417505',
    '#bd10e0',
    '#9013fe',
    '#4a90e2',
    '#50e3c2',
    '#b8e986',
    '#000',
    '#444',
    '#888',
    '#ccc',
    '#fff'
  ];

  /** Emitted when the color picker's value changes. */
  @Event({ eventName: 'sl-change' }) slChange: EventEmitter;

  /** Emitted when the color picker opens. Calling `event.preventDefault()` will prevent it from being opened. */
  @Event({ eventName: 'sl-show' }) slShow: EventEmitter;

  /** Emitted after the color picker opens and all transitions are complete. */
  @Event({ eventName: 'sl-after-show' }) slAfterShow: EventEmitter;

  /** Emitted when the color picker closes. Calling `event.preventDefault()` will prevent it from being closed. */
  @Event({ eventName: 'sl-hide' }) slHide: EventEmitter;

  /** Emitted after the color picker closes and all transitions are complete. */
  @Event({ eventName: 'sl-after-hide' }) slAfterHide: EventEmitter;

  @Watch('value')
  handleValueChange(newValue: string, oldValue: string) {
    if (!this.bypassValueParse) {
      const newColor = this.parseColor(newValue);

      if (newColor) {
        this.inputValue = this.value;
        this.hue = newColor.hsla.h;
        this.saturation = newColor.hsla.s;
        this.lightness = newColor.hsla.l;
        this.alpha = newColor.hsla.a * 100;
      } else {
        this.inputValue = oldValue;
      }
    }

    if (this.value !== this.lastValueEmitted) {
      this.slChange.emit();
      this.lastValueEmitted = this.value;
    }
  }

  connectedCallback() {
    this.handleAlphaDrag = this.handleAlphaDrag.bind(this);
    this.handleAlphaInput = this.handleAlphaInput.bind(this);
    this.handleAlphaKeyDown = this.handleAlphaKeyDown.bind(this);
    this.handleCopy = this.handleCopy.bind(this);
    this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
    this.handleDrag = this.handleDrag.bind(this);
    this.handleDropdownAfterHide = this.handleDropdownAfterHide.bind(this);
    this.handleDropdownAfterShow = this.handleDropdownAfterShow.bind(this);
    this.handleDropdownHide = this.handleDropdownHide.bind(this);
    this.handleDropdownShow = this.handleDropdownShow.bind(this);
    this.handleGridDrag = this.handleGridDrag.bind(this);
    this.handleGridKeyDown = this.handleGridKeyDown.bind(this);
    this.handleHueDrag = this.handleHueDrag.bind(this);
    this.handleHueInput = this.handleHueInput.bind(this);
    this.handleHueKeyDown = this.handleHueKeyDown.bind(this);
    this.handleLightnessInput = this.handleLightnessInput.bind(this);
    this.handleSaturationInput = this.handleSaturationInput.bind(this);
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
  }

  componentWillLoad() {
    if (!this.setColor(this.value)) {
      this.setColor(`#ffff`);
    }

    this.inputValue = this.value;
    this.lastValueEmitted = this.value;
    this.syncValues();
  }

  /** Checks for validity and shows the browser's validation message if the control is invalid. */
  @Method()
  async reportValidity() {
    // If the input is invalid, show the dropdown so the browser can focus on it
    if (!this.inline && this.input.invalid) {
      // this.dropdown.show();
      // setTimeout(() => {
      //   this.input.reportValidity();
      // }, 300);

      return new Promise(resolve => {
        this.dropdown.addEventListener(
          'sl-after-show',
          () => {
            this.input.reportValidity();
            resolve();
          },
          { once: true }
        );
        this.dropdown.show();
      });
    } else {
      return this.input.reportValidity();
    }
  }

  /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
  @Method()
  async setCustomValidity(message: string) {
    await this.input.setCustomValidity(message);
    this.invalid = this.input.invalid;
  }

  handleCopy() {
    this.input.select().then(() => {
      document.execCommand('copy');
      this.copyButton.setFocus();
      this.showCopyCheckmark = true;
      setTimeout(() => (this.showCopyCheckmark = false), 1000);
    });
  }

  handleHueInput(event: Event) {
    const target = event.target as HTMLInputElement;
    this.hue = clamp(Number(target.value), 0, 360);
  }

  handleSaturationInput(event: Event) {
    const target = event.target as HTMLInputElement;
    this.saturation = clamp(Number(target.value), 0, 100);
  }

  handleLightnessInput(event: Event) {
    const target = event.target as HTMLInputElement;
    this.lightness = clamp(Number(target.value), 0, 100);
  }

  handleAlphaInput(event: Event) {
    const target = event.target as HTMLInputElement;
    this.alpha = clamp(Number(target.value), 0, 100);
  }

  handleAlphaDrag(event: any) {
    const container = this.host.shadowRoot.querySelector('.color-picker__slider.color-picker__alpha') as HTMLElement;
    const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement;
    const { width } = container.getBoundingClientRect();

    handle.focus();
    event.preventDefault();

    this.handleDrag(event, container, x => {
      this.alpha = clamp((x / width) * 100, 0, 100);
      this.syncValues();
    });
  }

  handleHueDrag(event: any) {
    const container = this.host.shadowRoot.querySelector('.color-picker__slider.color-picker__hue') as HTMLElement;
    const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement;
    const { width } = container.getBoundingClientRect();

    handle.focus();
    event.preventDefault();

    this.handleDrag(event, container, x => {
      this.hue = clamp((x / width) * 360, 0, 360);
      this.syncValues();
    });
  }

  handleGridDrag(event: any) {
    const grid = this.host.shadowRoot.querySelector('.color-picker__grid') as HTMLElement;
    const handle = grid.querySelector('.color-picker__grid-handle') as HTMLElement;
    const { width, height } = grid.getBoundingClientRect();

    handle.focus();
    event.preventDefault();

    this.handleDrag(event, grid, (x, y) => {
      this.saturation = clamp((x / width) * 100, 0, 100);
      this.lightness = clamp(100 - (y / height) * 100, 0, 100);
      this.syncValues();
    });
  }

  handleDrag(event: any, container: HTMLElement, onMove: (x: number, y: number) => void) {
    if (this.disabled) {
      return false;
    }

    const move = (event: any) => {
      const dims = container.getBoundingClientRect();
      const offsetX = dims.left + container.ownerDocument.defaultView.pageXOffset;
      const offsetY = dims.top + container.ownerDocument.defaultView.pageYOffset;
      const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX;
      const y = (event.changedTouches ? event.changedTouches[0].pageY : event.pageY) - offsetY;

      onMove(x, y);
    };

    // Move on init
    move(event);

    const stop = () => {
      document.removeEventListener('mousemove', move);
      document.removeEventListener('touchmove', move);
      document.removeEventListener('mouseup', stop);
      document.removeEventListener('touchend', stop);
    };

    document.addEventListener('mousemove', move);
    document.addEventListener('touchmove', move);
    document.addEventListener('mouseup', stop);
    document.addEventListener('touchend', stop);
  }

  handleAlphaKeyDown(event: KeyboardEvent) {
    const increment = event.shiftKey ? 10 : 1;

    if (event.key === 'ArrowLeft') {
      event.preventDefault();
      this.alpha = clamp(this.alpha - increment, 0, 100);
      this.syncValues();
    }

    if (event.key === 'ArrowRight') {
      event.preventDefault();
      this.alpha = clamp(this.alpha + increment, 0, 100);
      this.syncValues();
    }

    if (event.key === 'Home') {
      event.preventDefault();
      this.alpha = 0;
      this.syncValues();
    }

    if (event.key === 'End') {
      event.preventDefault();
      this.alpha = 100;
      this.syncValues();
    }
  }

  handleHueKeyDown(event: KeyboardEvent) {
    const increment = event.shiftKey ? 10 : 1;

    if (event.key === 'ArrowLeft') {
      event.preventDefault();
      this.hue = clamp(this.hue - increment, 0, 360);
      this.syncValues();
    }

    if (event.key === 'ArrowRight') {
      event.preventDefault();
      this.hue = clamp(this.hue + increment, 0, 360);
      this.syncValues();
    }

    if (event.key === 'Home') {
      event.preventDefault();
      this.hue = 0;
      this.syncValues();
    }

    if (event.key === 'End') {
      event.preventDefault();
      this.hue = 360;
      this.syncValues();
    }
  }

  handleGridKeyDown(event: KeyboardEvent) {
    const increment = event.shiftKey ? 10 : 1;

    if (event.key === 'ArrowLeft') {
      event.preventDefault();
      this.saturation = clamp(this.saturation - increment, 0, 100);
      this.syncValues();
    }

    if (event.key === 'ArrowRight') {
      event.preventDefault();
      this.saturation = clamp(this.saturation + increment, 0, 100);
      this.syncValues();
    }

    if (event.key === 'ArrowUp') {
      event.preventDefault();
      this.lightness = clamp(this.lightness + increment, 0, 100);
      this.syncValues();
    }

    if (event.key === 'ArrowDown') {
      event.preventDefault();
      this.lightness = clamp(this.lightness - increment, 0, 100);
      this.syncValues();
    }
  }

  handleInputChange(event: CustomEvent) {
    const target = event.target as HTMLInputElement;

    this.setColor(target.value);
    target.value = this.value;
    event.stopPropagation();
  }

  handleInputKeyDown(event: KeyboardEvent) {
    if (event.key === 'Enter') {
      this.setColor(this.input.value);
      this.input.value = this.value;
      setTimeout(() => this.input.select());
    }
  }

  handleDocumentMouseDown(event: MouseEvent) {
    const target = event.target as HTMLElement;

    // Close when clicking outside of the dropdown
    if (target.closest('sl-color-picker') !== this.host) {
      this.dropdown.hide();
    }
  }

  handleDropdownShow(event: CustomEvent) {
    event.stopPropagation();
    this.slShow.emit();
  }

  handleDropdownAfterShow(event: CustomEvent) {
    event.stopPropagation();
    this.slAfterShow.emit();
  }

  handleDropdownHide(event: CustomEvent) {
    event.stopPropagation();
    this.slHide.emit();
  }

  handleDropdownAfterHide(event: CustomEvent) {
    event.stopPropagation();
    this.slAfterHide.emit();
  }

  normalizeColorString(colorString: string) {
    //
    // The color module we're using doesn't parse % values for the alpha channel in RGBA and HSLA. It also doesn't parse
    // hex colors when the # is missing. This pre-parser tries to normalize these edge cases to provide a better
    // experience for users who type in color values.
    //
    if (/rgba?/i.test(colorString)) {
      const rgba = colorString
        .replace(/[^\d.%]/g, ' ')
        .split(' ')
        .map(val => val.trim())
        .filter(val => val.length);

      if (rgba.length < 4) {
        rgba[3] = '1';
      }

      if (rgba[3].indexOf('%') > -1) {
        rgba[3] = (Number(rgba[3].replace(/%/g, '')) / 100).toString();
      }

      return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${rgba[3]})`;
    }

    if (/hsla?/i.test(colorString)) {
      const hsla = colorString
        .replace(/[^\d.%]/g, ' ')
        .split(' ')
        .map(val => val.trim())
        .filter(val => val.length);

      if (hsla.length < 4) {
        hsla[3] = '1';
      }

      if (hsla[3].indexOf('%') > -1) {
        hsla[3] = (Number(hsla[3].replace(/%/g, '')) / 100).toString();
      }

      return `hsla(${hsla[0]}, ${hsla[1]}, ${hsla[2]}, ${hsla[3]})`;
    }

    if (/^[0-9a-f]+$/i.test(colorString)) {
      return `#${colorString}`;
    }

    return colorString;
  }

  parseColor(colorString: string) {
    function toHex(value: number) {
      const hex = Math.round(value).toString(16);
      return hex.length === 1 ? `0${hex}` : hex;
    }

    let parsed: any;

    // The color module has a weak parser, so we normalize certain things to make the user experience better
    colorString = this.normalizeColorString(colorString);

    try {
      parsed = color(colorString);
    } catch {
      return false;
    }

    const hsl = {
      h: parsed.hsl().color[0],
      s: parsed.hsl().color[1],
      l: parsed.hsl().color[2],
      a: parsed.hsl().valpha
    };

    const rgb = {
      r: parsed.rgb().color[0],
      g: parsed.rgb().color[1],
      b: parsed.rgb().color[2],
      a: parsed.rgb().valpha
    };

    const hex = {
      r: toHex(parsed.rgb().color[0]),
      g: toHex(parsed.rgb().color[1]),
      b: toHex(parsed.rgb().color[2]),
      a: toHex(parsed.rgb().valpha * 255)
    };

    return {
      hsl: {
        h: hsl.h,
        s: hsl.s,
        l: hsl.l,
        string: this.setLetterCase(`hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%)`)
      },
      hsla: {
        h: hsl.h,
        s: hsl.s,
        l: hsl.l,
        a: hsl.a,
        string: this.setLetterCase(
          `hsla(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%, ${Number(
            hsl.a.toFixed(2).toString()
          )})`
        )
      },
      rgb: {
        r: rgb.r,
        g: rgb.g,
        b: rgb.b,
        string: this.setLetterCase(`rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`)
      },
      rgba: {
        r: rgb.r,
        g: rgb.g,
        b: rgb.b,
        a: rgb.a,
        string: this.setLetterCase(
          `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${Number(
            rgb.a.toFixed(2).toString()
          )})`
        )
      },
      hex: this.setLetterCase(`#${hex.r}${hex.g}${hex.b}`),
      hexa: this.setLetterCase(`#${hex.r}${hex.g}${hex.b}${hex.a}`)
    };
  }

  setColor(colorString: string) {
    const newColor = this.parseColor(colorString);

    if (!newColor) {
      return false;
    }

    this.hue = newColor.hsla.h;
    this.saturation = newColor.hsla.s;
    this.lightness = newColor.hsla.l;
    this.alpha = this.opacity ? newColor.hsla.a * 100 : 100;

    this.syncValues();

    return true;
  }

  setLetterCase(string: string) {
    return this.uppercase ? string.toUpperCase() : string.toLowerCase();
  }

  syncValues() {
    const currentColor = this.parseColor(
      `hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
    );

    if (!currentColor) {
      return false;
    }

    // Update the value
    if (this.format === 'hsl') {
      this.inputValue = this.opacity ? currentColor.hsla.string : currentColor.hsl.string;
    } else if (this.format === 'rgb') {
      this.inputValue = this.opacity ? currentColor.rgba.string : currentColor.rgb.string;
    } else {
      this.inputValue = this.opacity ? currentColor.hexa : currentColor.hex;
    }

    // Setting this.value will trigger the watcher which parses the new color. We want to bypass that behavior because
    // a) we've already done it in this function and b) conversion/rounding can lead to values changing slightly.
    this.bypassValueParse = true;
    this.value = this.inputValue;
    this.bypassValueParse = false;
  }

  render() {
    const x = this.saturation;
    const y = 100 - this.lightness;

    const ColorPicker = () => {
      return (
        <div
          part="base"
          class={{
            'color-picker': true,
            'color-picker--inline': this.inline,
            'color-picker--disabled': this.disabled
          }}
          aria-disabled={this.disabled}
        >
          <div
            part="grid"
            class="color-picker__grid"
            style={{
              backgroundColor: `hsl(${this.hue}deg, 100%, 50%)`
            }}
            onMouseDown={this.handleGridDrag}
            onTouchStart={this.handleGridDrag}
          >
            <span
              part="grid-handle"
              class="color-picker__grid-handle"
              style={{
                top: `${y}%`,
                left: `${x}%`
              }}
              role="slider"
              aria-label="HSL"
              aria-valuetext={`hsl(${Math.round(this.hue)}, ${Math.round(this.saturation)}%, ${Math.round(
                this.lightness
              )}%)`}
              tabIndex={this.disabled ? null : 0}
              onKeyDown={this.handleGridKeyDown}
            />
          </div>

          <div class="color-picker__controls">
            <div class="color-picker__sliders">
              <div
                part="slider hue-slider"
                class="color-picker__hue color-picker__slider"
                onMouseDown={this.handleHueDrag}
                onTouchStart={this.handleHueDrag}
              >
                <span
                  part="slider-handle"
                  class="color-picker__slider-handle"
                  style={{
                    left: `${this.hue === 0 ? 0 : 100 / (360 / this.hue)}%`
                  }}
                  role="slider"
                  aria-label="hue"
                  aria-orientation="horizontal"
                  aria-valuemin="0"
                  aria-valuemax="360"
                  aria-valuenow={Math.round(this.hue)}
                  tabIndex={this.disabled ? null : 0}
                  onKeyDown={this.handleHueKeyDown}
                />
              </div>

              {this.opacity && (
                <div
                  part="slider opacity-slider"
                  class="color-picker__alpha color-picker__slider color-picker__transparent-bg"
                  onMouseDown={this.handleAlphaDrag}
                  onTouchStart={this.handleAlphaDrag}
                >
                  <div
                    class="color-picker__alpha-gradient"
                    style={{
                      backgroundImage: `linear-gradient(
                      to right,
                      hsl(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, 0%) 0%,
                      hsl(${this.hue}deg, ${this.saturation}%, ${this.lightness}%) 100%
                      )`
                    }}
                  />
                  <span
                    part="slider-handle"
                    class="color-picker__slider-handle"
                    style={{
                      left: `${this.alpha}%`
                    }}
                    role="slider"
                    aria-label="alpha"
                    aria-orientation="horizontal"
                    aria-valuemin="0"
                    aria-valuemax="100"
                    aria-valuenow={Math.round(this.alpha)}
                    tabIndex={this.disabled ? null : 0}
                    onKeyDown={this.handleAlphaKeyDown}
                  />
                </div>
              )}
            </div>

            <div
              part="preview"
              class="color-picker__preview color-picker__transparent-bg"
              style={{
                color: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
              }}
            />
          </div>

          <div class="color-picker__user-input">
            <sl-input
              ref={el => (this.input = el)}
              part="input"
              size="small"
              type="text"
              name={this.name}
              autocomplete="off"
              autocorrect="off"
              autocapitalize="off"
              spellcheck={false}
              value={this.inputValue}
              disabled={this.disabled}
              onKeyDown={this.handleInputKeyDown}
              onSl-change={this.handleInputChange}
            />
            <sl-button
              ref={el => (this.copyButton = el)}
              exportparts="base:copy-button"
              slot="suffix"
              class="color-picker__copy-button"
              size="small"
              circle
              onClick={this.handleCopy}
            >
              <sl-icon name={this.showCopyCheckmark ? 'check2' : 'clipboard'} />
            </sl-button>
          </div>

          {this.swatches && (
            <div part="swatches" class="color-picker__swatches">
              {this.swatches.map(swatch => (
                <div
                  part="swatch"
                  class="color-picker__swatch color-picker__transparent-bg"
                  tabIndex={this.disabled ? null : 0}
                  role="button"
                  aria-label={swatch}
                  onClick={() => !this.disabled && this.setColor(swatch)}
                  onKeyDown={event => !this.disabled && event.key === 'Enter' && this.setColor(swatch)}
                >
                  <div class="color-picker__swatch-color" style={{ backgroundColor: swatch }} />
                </div>
              ))}
            </div>
          )}
        </div>
      );
    };

    // Render inline
    if (this.inline) {
      return <ColorPicker />;
    }

    // Render as a dropdown
    return (
      <sl-dropdown
        ref={el => (this.dropdown = el)}
        class="color-dropdown"
        aria-disabled={this.disabled}
        containingElement={this.host}
        hoist={this.hoist}
        onSl-show={this.handleDropdownShow}
        onSl-after-show={this.handleDropdownAfterShow}
        onSl-hide={this.handleDropdownHide}
        onSl-after-hide={this.handleDropdownAfterHide}
      >
        <button
          ref={el => (this.trigger = el)}
          part="trigger"
          slot="trigger"
          class={{
            'color-dropdown__trigger': true,
            'color-dropdown__trigger--disabled': this.disabled,
            'color-dropdown__trigger--small': this.size === 'small',
            'color-dropdown__trigger--medium': this.size === 'medium',
            'color-dropdown__trigger--large': this.size === 'large',
            'color-picker__transparent-bg': true
          }}
          style={{
            color: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
          }}
          type="button"
        />
        <ColorPicker />
      </sl-dropdown>
    );
  }
}
