/**
 * @license
 * Copyright 2023 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Colour input field.
 */

import * as Blockly from 'blockly/core';

/**
 * Class for a colour input field.
 */
export class FieldColour extends Blockly.Field<string> {
  /** The field's colour picker element. */
  private picker: HTMLElement | null = null;

  /** Index of the currently highlighted element. */
  private highlightedIndex: number | null = null;

  /**
   * Array holding info needed to unbind events.
   * Used for disposing.
   * Ex: [[node, name, func], [node, name, func]].
   */
  private boundEvents: Blockly.browserEvents.Data[] = [];

  /**
   * Serializable fields are saved by the serializer, non-serializable fields
   * are not.  Editable fields should also be serializable.
   */
  override SERIALIZABLE = true;

  /**
   * Used to tell if the field needs to be rendered the next time the block is
   * rendered.  Colour fields are statically sized, and only need to be
   * rendered at initialization.
   */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  protected override isDirty_ = false;

  /**
   * An array of colour strings for the palette.
   * Copied from goog.ui.ColorPicker.SIMPLE_GRID_COLORS
   */
  private colours: string[] = [
    // grays
    '#ffffff',
    '#cccccc',
    '#c0c0c0',
    '#999999',
    '#666666',
    '#333333',
    '#000000',
    // reds
    '#ffcccc',
    '#ff6666',
    '#ff0000',
    '#cc0000',
    '#990000',
    '#660000',
    '#330000',
    // oranges
    '#ffcc99',
    '#ff9966',
    '#ff9900',
    '#ff6600',
    '#cc6600',
    '#993300',
    '#663300',
    // yellows
    '#ffff99',
    '#ffff66',
    '#ffcc66',
    '#ffcc33',
    '#cc9933',
    '#996633',
    '#663333',
    // olives
    '#ffffcc',
    '#ffff33',
    '#ffff00',
    '#ffcc00',
    '#999900',
    '#666600',
    '#333300',
    // greens
    '#99ff99',
    '#66ff99',
    '#33ff33',
    '#33cc00',
    '#009900',
    '#006600',
    '#003300',
    // turquoises
    '#99ffff',
    '#33ffff',
    '#66cccc',
    '#00cccc',
    '#339999',
    '#336666',
    '#003333',
    // blues
    '#ccffff',
    '#66ffff',
    '#33ccff',
    '#3366ff',
    '#3333ff',
    '#000099',
    '#000066',
    // purples
    '#ccccff',
    '#9999ff',
    '#6666cc',
    '#6633ff',
    '#6600cc',
    '#333399',
    '#330099',
    // violets
    '#ffccff',
    '#ff99ff',
    '#cc66cc',
    '#cc33cc',
    '#993399',
    '#663366',
    '#330033',
  ];

  /**
   * An array of tooltip strings for the palette.  If not the same length as
   * COLOURS, the colour's hex code will be used for any missing titles.
   */
  private titles: string[] = [];

  /**
   * Number of columns in the palette.
   */
  private columns = 7;

  /**
   * @param value The initial value of the field.  Should be in '#rrggbb'
   *     format.  Defaults to the first value in the default colour array.  Also
   *     accepts Field.SKIP_SETUP if you wish to skip setup (only used by
   *     subclasses that want to handle configuration and setting the field
   *     value after their own constructors have run).
   * @param validator A function that is called to validate changes to the
   *     field's value.  Takes in a colour string & returns a validated colour
   *     string ('#rrggbb' format), or null to abort the change.
   * @param config A map of options used to configure the field.
   *     See the [field creation documentation]{@link
   * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/colour}
   * for a list of properties this parameter supports.
   */
  constructor(
    value?: string | typeof Blockly.Field.SKIP_SETUP,
    validator?: FieldColourValidator,
    config?: FieldColourConfig,
  ) {
    super(Blockly.Field.SKIP_SETUP);

    if (value === Blockly.Field.SKIP_SETUP) return;
    if (config) {
      this.configure_(config);
    }
    this.setValue(value);
    if (validator) {
      this.setValidator(validator);
    }
  }

  /**
   * Configure the field based on the given map of options.
   *
   * @param config A map of options to configure the field based on.
   */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  protected override configure_(config: FieldColourConfig) {
    super.configure_(config);
    if (config.colourOptions) this.colours = config.colourOptions;
    if (config.colourTitles) this.titles = config.colourTitles;
    if (config.columns) this.columns = config.columns;
  }

  /**
   * Create the block UI for this colour field.
   *
   * @internal
   */
  override initView() {
    const constants = this.getConstants();
    // This can't happen, but TypeScript thinks it can and lint forbids `!.`.
    if (!constants) throw Error('Constants not found');
    this.size_ = new Blockly.utils.Size(
      constants.FIELD_COLOUR_DEFAULT_WIDTH,
      constants.FIELD_COLOUR_DEFAULT_HEIGHT,
    );
    this.createBorderRect_();
    this.getBorderRect().style['fillOpacity'] = '1';
    this.getBorderRect().setAttribute('stroke', '#fff');
    if (this.isFullBlockField()) {
      this.clickTarget_ = (this.sourceBlock_ as Blockly.BlockSvg).getSvgRoot();
    }
  }

  /**
   * Defines whether this field should take up the full block or not.
   *
   * @returns True if this field should take up the full block. False otherwise.
   */
  override isFullBlockField(): boolean {
    const block = this.getSourceBlock();
    if (!block) throw new Blockly.UnattachedFieldError();

    const constants = this.getConstants();
    return (
      this.blockIsSimpleReporter() &&
      Boolean(constants?.FIELD_COLOUR_FULL_BLOCK)
    );
  }

  /**
   * @returns True if the source block is a value block with a single editable
   *     field.
   * @internal
   */
  blockIsSimpleReporter(): boolean {
    const block = this.getSourceBlock();
    if (!block) throw new Blockly.UnattachedFieldError();

    if (!block.outputConnection) return false;

    for (const input of block.inputList) {
      if (input.connection || input.fieldRow.length > 1) return false;
    }
    return true;
  }

  /**
   * Updates text field to match the colour/style of the block.
   *
   * @internal
   */
  override applyColour() {
    const block = this.getSourceBlock() as Blockly.BlockSvg | null;
    if (!block) throw new Blockly.UnattachedFieldError();

    if (!this.fieldGroup_) return;

    const borderRect = this.borderRect_;
    if (!borderRect) {
      throw new Error('The border rect has not been initialized');
    }

    if (!this.isFullBlockField()) {
      borderRect.style.display = 'block';
      borderRect.style.fill = this.getValue() as string;
    } else {
      borderRect.style.display = 'none';
      // In general, do *not* let fields control the color of blocks. Having the
      // field control the color is unexpected, and could have performance
      // impacts.
      block.pathObject.svgPath.setAttribute('fill', this.getValue() as string);
      block.pathObject.svgPath.setAttribute('stroke', '#fff');
    }
  }

  /**
   * Returns the height and width of the field.
   *
   * This should *in general* be the only place render_ gets called from.
   *
   * @returns Height and width.
   */
  override getSize(): Blockly.utils.Size {
    if (this.getConstants()?.FIELD_COLOUR_FULL_BLOCK) {
      // In general, do *not* let fields control the color of blocks. Having the
      // field control the color is unexpected, and could have performance
      // impacts.
      // Full block fields have more control of the block than they should
      // (i.e. updating fill colour) so they always need to be rerendered.
      this.render_();
      this.isDirty_ = false;
    }
    return super.getSize();
  }

  /**
   * Updates the colour of the block to reflect whether this is a full
   * block field or not.
   */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  protected override render_() {
    super.render_();

    const block = this.getSourceBlock() as Blockly.BlockSvg | null;
    if (!block) throw new Blockly.UnattachedFieldError();
    // Calling applyColour updates the UI (full-block vs non-full-block) for the
    // colour field, and the colour of the field/block.
    block.applyColour();
  }

  /**
   * Updates the size of the field based on whether it is a full block field
   * or not.
   *
   * @param margin margin to use when positioning the field.
   */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  protected updateSize_(margin?: number) {
    const constants = this.getConstants();
    if (!constants) return;
    let totalWidth;
    let totalHeight;
    if (this.isFullBlockField()) {
      const xOffset = margin ?? 0;
      totalWidth = xOffset * 2;
      totalHeight = constants.FIELD_TEXT_HEIGHT;
    } else {
      totalWidth = constants.FIELD_COLOUR_DEFAULT_WIDTH;
      totalHeight = constants.FIELD_COLOUR_DEFAULT_HEIGHT;
    }

    this.size_.height = totalHeight;
    this.size_.width = totalWidth;

    this.positionBorderRect_();
  }

  /**
   * Ensure that the input value is a valid colour.
   *
   * @param newValue The input value.
   * @returns A valid colour, or null if invalid.
   */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  protected override doClassValidation_(
    newValue: string,
  ): string | null | undefined;
  // eslint-disable-next-line @typescript-eslint/naming-convention
  protected override doClassValidation_(newValue?: string): string | null;
  // eslint-disable-next-line @typescript-eslint/naming-convention
  protected override doClassValidation_(
    newValue?: string,
  ): string | null | undefined {
    if (typeof newValue !== 'string') {
      return null;
    }
    return Blockly.utils.colour.parse(newValue);
  }

  /**
   * Get the text for this field.  Used when the block is collapsed.
   *
   * @returns Text representing the value of this field.
   */
  override getText(): string {
    let colour = this.value_ as string;
    // Try to use #rgb format if possible, rather than #rrggbb.
    if (/^#(.)\1(.)\2(.)\3$/.test(colour)) {
      colour = '#' + colour[1] + colour[3] + colour[5];
    }
    return colour;
  }

  /**
   * Set a custom colour grid for this field.
   *
   * @param colours Array of colours for this block, or null to use default
   *     (FieldColour.COLOURS).
   * @param titles Optional array of colour tooltips, or null to use default
   *     (FieldColour.TITLES).
   * @returns Returns itself (for method chaining).
   */
  setColours(colours: string[], titles?: string[]): FieldColour {
    this.colours = colours;
    if (titles) {
      this.titles = titles;
    }
    return this;
  }

  /**
   * Set a custom grid size for this field.
   *
   * @param columns Number of columns for this block, or 0 to use default
   *     (FieldColour.COLUMNS).
   * @returns Returns itself (for method chaining).
   */
  setColumns(columns: number): FieldColour {
    this.columns = columns;
    return this;
  }

  /** Create and show the colour field's editor. */
  // eslint-disable-next-line @typescript-eslint/naming-convention
  protected override showEditor_() {
    this.dropdownCreate();
    // This can't happen, but TypeScript thinks it can and lint forbids `!.`.
    if (!this.picker) throw Error('Picker not found');
    Blockly.DropDownDiv.getContentDiv().appendChild(this.picker);

    Blockly.DropDownDiv.showPositionedByField(
      this,
      this.dropdownDispose.bind(this),
    );

    // Focus so we can start receiving keyboard events.
    this.picker.focus({preventScroll: true});
  }

  /**
   * Handle a click on a colour cell.
   *
   * @param e Mouse event.
   */
  private onClick(e: PointerEvent) {
    const cell = e.target as Element;
    const colour = cell && cell.getAttribute('data-colour');
    if (colour !== null) {
      this.setValue(colour);
      Blockly.DropDownDiv.hideIfOwner(this);
    }
  }

  /**
   * Handle a key down event.  Navigate around the grid with the
   * arrow keys.  Enter selects the highlighted colour.
   *
   * @param e Keyboard event.
   */
  private onKeyDown(e: KeyboardEvent) {
    let handled = true;
    let highlighted: HTMLElement | null;
    switch (e.key) {
      case 'ArrowUp':
        this.moveHighlightBy(0, -1);
        break;
      case 'ArrowDown':
        this.moveHighlightBy(0, 1);
        break;
      case 'ArrowLeft':
        this.moveHighlightBy(-1, 0);
        break;
      case 'ArrowRight':
        this.moveHighlightBy(1, 0);
        break;
      case 'Enter':
        // Select the highlighted colour.
        highlighted = this.getHighlighted();
        if (highlighted) {
          const colour = highlighted.getAttribute('data-colour');
          if (colour !== null) {
            this.setValue(colour);
          }
        }
        Blockly.DropDownDiv.hideWithoutAnimation();
        break;
      default:
        handled = false;
    }
    if (handled) {
      e.stopPropagation();
    }
  }

  /**
   * Move the currently highlighted position by dx and dy.
   *
   * @param dx Change of x.
   * @param dy Change of y.
   */
  private moveHighlightBy(dx: number, dy: number) {
    if (this.highlightedIndex === null) {
      return;
    }

    const colours = this.colours;
    const columns = this.columns;

    // Get the current x and y coordinates.
    let x = this.highlightedIndex % columns;
    let y = Math.floor(this.highlightedIndex / columns);

    // Add the offset.
    x += dx;
    y += dy;

    if (dx < 0) {
      // Move left one grid cell, even in RTL.
      // Loop back to the end of the previous row if we have room.
      if (x < 0 && y > 0) {
        x = columns - 1;
        y--;
      } else if (x < 0) {
        x = 0;
      }
    } else if (dx > 0) {
      // Move right one grid cell, even in RTL.
      // Loop to the start of the next row, if there's room.
      if (x > columns - 1 && y < Math.floor(colours.length / columns) - 1) {
        x = 0;
        y++;
      } else if (x > columns - 1) {
        x--;
      }
    } else if (dy < 0) {
      // Move up one grid cell, stop at the top.
      if (y < 0) {
        y = 0;
      }
    } else if (dy > 0) {
      // Move down one grid cell, stop at the bottom.
      if (y > Math.floor(colours.length / columns) - 1) {
        y = Math.floor(colours.length / columns) - 1;
      }
    }

    // Move the highlight to the new coordinates.
    const cell = (this.picker as HTMLElement).childNodes[y].childNodes[
      x
    ] as Element;
    const index = y * columns + x;
    this.setHighlightedCell(cell, index);
  }

  /**
   * Handle a mouse move event.  Highlight the hovered colour.
   *
   * @param e Mouse event.
   */
  private onMouseMove(e: PointerEvent) {
    const cell = e.target as Element;
    const index = cell && Number(cell.getAttribute('data-index'));
    if (index !== null && index !== this.highlightedIndex) {
      this.setHighlightedCell(cell, index);
    }
  }

  /** Handle a mouse enter event.  Focus the picker. */
  private onMouseEnter() {
    this.picker?.focus({preventScroll: true});
  }

  /**
   * Handle a mouse leave event by unnhighlighting the currently highlighted
   * colour.
   */
  private onMouseLeave() {
    const highlighted = this.getHighlighted();
    if (highlighted) {
      Blockly.utils.dom.removeClass(highlighted, 'blocklyColourHighlighted');
    }
  }

  /**
   * Returns the currently highlighted item (if any).
   *
   * @returns Highlighted item (null if none).
   */
  private getHighlighted(): HTMLElement | null {
    if (this.highlightedIndex === null) {
      return null;
    }

    const x = this.highlightedIndex % this.columns;
    const y = Math.floor(this.highlightedIndex / this.columns);
    const row = this.picker?.childNodes[y];
    if (!row) {
      return null;
    }
    return row.childNodes[x] as HTMLElement;
  }

  /**
   * Update the currently highlighted cell.
   *
   * @param cell The new cell to highlight.
   * @param index The index of the new cell.
   */
  private setHighlightedCell(cell: Element, index: number) {
    // Unhighlight the current item.
    const highlighted = this.getHighlighted();
    if (highlighted) {
      Blockly.utils.dom.removeClass(highlighted, 'blocklyColourHighlighted');
    }
    // Highlight new item.
    Blockly.utils.dom.addClass(cell, 'blocklyColourHighlighted');
    // Set new highlighted index.
    this.highlightedIndex = index;

    // Update accessibility roles.
    const cellId = cell.getAttribute('id');
    if (cellId && this.picker) {
      Blockly.utils.aria.setState(
        this.picker,
        Blockly.utils.aria.State.ACTIVEDESCENDANT,
        cellId,
      );
    }
  }

  /** Create a colour picker dropdown editor. */
  private dropdownCreate() {
    const columns = this.columns;
    const colours = this.colours;
    const selectedColour = this.getValue();
    // Create the palette.
    const table = document.createElement('table');
    table.className = 'blocklyColourTable';
    table.tabIndex = 0;
    table.dir = 'ltr';
    Blockly.utils.aria.setRole(table, Blockly.utils.aria.Role.GRID);
    Blockly.utils.aria.setState(table, Blockly.utils.aria.State.EXPANDED, true);
    Blockly.utils.aria.setState(
      table,
      Blockly.utils.aria.State.ROWCOUNT,
      Math.floor(colours.length / columns),
    );
    Blockly.utils.aria.setState(
      table,
      Blockly.utils.aria.State.COLCOUNT,
      columns,
    );
    let row: Element | null = null;
    for (let i = 0; i < colours.length; i++) {
      if (i % columns === 0) {
        row = document.createElement('tr');
        Blockly.utils.aria.setRole(row, Blockly.utils.aria.Role.ROW);
        table.appendChild(row);
      }
      const cell = document.createElement('td');
      (row as Element).appendChild(cell);
      // This becomes the value, if clicked.
      cell.setAttribute('data-colour', colours[i]);
      cell.title = this.titles[i] || colours[i];
      cell.id = Blockly.utils.idGenerator.getNextUniqueId();
      cell.setAttribute('data-index', `${i}`);
      Blockly.utils.aria.setRole(cell, Blockly.utils.aria.Role.GRIDCELL);
      Blockly.utils.aria.setState(
        cell,
        Blockly.utils.aria.State.LABEL,
        colours[i],
      );
      Blockly.utils.aria.setState(
        cell,
        Blockly.utils.aria.State.SELECTED,
        colours[i] === selectedColour,
      );
      cell.style.backgroundColor = colours[i];
      if (colours[i] === selectedColour) {
        cell.className = 'blocklyColourSelected';
        this.highlightedIndex = i;
      }
    }

    // Configure event handler on the table to listen for any event in a cell.
    this.boundEvents.push(
      Blockly.browserEvents.conditionalBind(
        table,
        'pointerdown',
        this,
        this.onClick,
        true,
      ),
    );
    this.boundEvents.push(
      Blockly.browserEvents.conditionalBind(
        table,
        'pointermove',
        this,
        this.onMouseMove,
        true,
      ),
    );
    this.boundEvents.push(
      Blockly.browserEvents.conditionalBind(
        table,
        'pointerenter',
        this,
        this.onMouseEnter,
        true,
      ),
    );
    this.boundEvents.push(
      Blockly.browserEvents.conditionalBind(
        table,
        'pointerleave',
        this,
        this.onMouseLeave,
        true,
      ),
    );
    this.boundEvents.push(
      Blockly.browserEvents.conditionalBind(
        table,
        'keydown',
        this,
        this.onKeyDown,
        false,
      ),
    );

    this.picker = table;
  }

  /** Disposes of events and DOM-references belonging to the colour editor. */
  private dropdownDispose() {
    for (const event of this.boundEvents) {
      Blockly.browserEvents.unbind(event);
    }
    this.boundEvents.length = 0;
    this.picker = null;
    this.highlightedIndex = null;
  }

  /**
   * Construct a FieldColour from a JSON arg object.
   *
   * @param options A JSON object with options (colour).
   * @returns The new field instance.
   * @nocollapse
   * @internal
   */
  static fromJson(options: FieldColourFromJsonConfig): FieldColour {
    // `this` might be a subclass of FieldColour if that class doesn't override
    // the static fromJson method.
    return new this(options.colour, undefined, options);
  }
}

/** The default value for this field. */
FieldColour.prototype.DEFAULT_VALUE = '#ffffff';

/**
 * Register the field and any dependencies.
 */
export function registerFieldColour() {
  Blockly.fieldRegistry.register('field_colour', FieldColour);
}

/**
 * CSS for colour picker.
 */
Blockly.Css.register(`
.blocklyColourTable {
  border-collapse: collapse;
  display: block;
  outline: none;
  padding: 1px;
}

.blocklyColourTable>tr>td {
  border: 0.5px solid #888;
  box-sizing: border-box;
  cursor: pointer;
  display: inline-block;
  height: 20px;
  padding: 0;
  width: 20px;
}

.blocklyColourTable>tr>td.blocklyColourHighlighted {
  border-color: #eee;
  box-shadow: 2px 2px 7px 2px rgba(0, 0, 0, 0.3);
  position: relative;
}

.blocklyColourSelected, .blocklyColourSelected:hover {
  border-color: #eee !important;
  outline: 1px solid #333;
  position: relative;
}
`);

/**
 * Config options for the colour field.
 */
export interface FieldColourConfig extends Blockly.FieldConfig {
  colourOptions?: string[];
  colourTitles?: string[];
  columns?: number;
}

/**
 * fromJson config options for the colour field.
 */
export interface FieldColourFromJsonConfig extends FieldColourConfig {
  colour?: string;
}

/**
 * A function that is called to validate changes to the field's value before
 * they are set.
 *
 * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values}
 * @param newValue The value to be validated.
 * @returns One of three instructions for setting the new value: `T`, `null`,
 * or `undefined`.
 *
 * - `T` to set this function's returned value instead of `newValue`.
 *
 * - `null` to invoke `doValueInvalid_` and not set a value.
 *
 * - `undefined` to set `newValue` as is.
 */
export type FieldColourValidator = Blockly.FieldValidator<string>;
