// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { ArrayExt } from '@lumino/algorithm';

import { ElementExt } from '@lumino/domutils';

import { Message, MessageLoop } from '@lumino/messaging';

import { AttachedProperty } from '@lumino/properties';

import { BoxEngine, BoxSizer } from './boxengine';

import { Layout, LayoutItem } from './layout';

import { Widget } from './widget';

/**
 * A layout which arranges its widgets in a grid.
 */
export class GridLayout extends Layout {
  /**
   * Construct a new grid layout.
   *
   * @param options - The options for initializing the layout.
   */
  constructor(options: GridLayout.IOptions = {}) {
    super(options);
    if (options.rowCount !== undefined) {
      Private.reallocSizers(this._rowSizers, options.rowCount);
    }
    if (options.columnCount !== undefined) {
      Private.reallocSizers(this._columnSizers, options.columnCount);
    }
    if (options.rowSpacing !== undefined) {
      this._rowSpacing = Private.clampValue(options.rowSpacing);
    }
    if (options.columnSpacing !== undefined) {
      this._columnSpacing = Private.clampValue(options.columnSpacing);
    }
  }

  /**
   * Dispose of the resources held by the layout.
   */
  dispose(): void {
    // Dispose of the widgets and layout items.
    for (const item of this._items) {
      let widget = item.widget;
      item.dispose();
      widget.dispose();
    }

    // Clear the layout state.
    this._box = null;
    this._items.length = 0;
    this._rowStarts.length = 0;
    this._rowSizers.length = 0;
    this._columnStarts.length = 0;
    this._columnSizers.length = 0;

    // Dispose of the rest of the layout.
    super.dispose();
  }

  /**
   * Get the number of rows in the layout.
   */
  get rowCount(): number {
    return this._rowSizers.length;
  }

  /**
   * Set the number of rows in the layout.
   *
   * #### Notes
   * The minimum row count is `1`.
   */
  set rowCount(value: number) {
    // Do nothing if the row count does not change.
    if (value === this.rowCount) {
      return;
    }

    // Reallocate the row sizers.
    Private.reallocSizers(this._rowSizers, value);

    // Schedule a fit of the parent.
    if (this.parent) {
      this.parent.fit();
    }
  }

  /**
   * Get the number of columns in the layout.
   */
  get columnCount(): number {
    return this._columnSizers.length;
  }

  /**
   * Set the number of columns in the layout.
   *
   * #### Notes
   * The minimum column count is `1`.
   */
  set columnCount(value: number) {
    // Do nothing if the column count does not change.
    if (value === this.columnCount) {
      return;
    }

    // Reallocate the column sizers.
    Private.reallocSizers(this._columnSizers, value);

    // Schedule a fit of the parent.
    if (this.parent) {
      this.parent.fit();
    }
  }

  /**
   * Get the row spacing for the layout.
   */
  get rowSpacing(): number {
    return this._rowSpacing;
  }

  /**
   * Set the row spacing for the layout.
   */
  set rowSpacing(value: number) {
    // Clamp the spacing to the allowed range.
    value = Private.clampValue(value);

    // Bail if the spacing does not change
    if (this._rowSpacing === value) {
      return;
    }

    // Update the internal spacing.
    this._rowSpacing = value;

    // Schedule a fit of the parent.
    if (this.parent) {
      this.parent.fit();
    }
  }

  /**
   * Get the column spacing for the layout.
   */
  get columnSpacing(): number {
    return this._columnSpacing;
  }

  /**
   * Set the col spacing for the layout.
   */
  set columnSpacing(value: number) {
    // Clamp the spacing to the allowed range.
    value = Private.clampValue(value);

    // Bail if the spacing does not change
    if (this._columnSpacing === value) {
      return;
    }

    // Update the internal spacing.
    this._columnSpacing = value;

    // Schedule a fit of the parent.
    if (this.parent) {
      this.parent.fit();
    }
  }

  /**
   * Get the stretch factor for a specific row.
   *
   * @param index - The row index of interest.
   *
   * @returns The stretch factor for the row.
   *
   * #### Notes
   * This returns `-1` if the index is out of range.
   */
  rowStretch(index: number): number {
    let sizer = this._rowSizers[index];
    return sizer ? sizer.stretch : -1;
  }

  /**
   * Set the stretch factor for a specific row.
   *
   * @param index - The row index of interest.
   *
   * @param value - The stretch factor for the row.
   *
   * #### Notes
   * This is a no-op if the index is out of range.
   */
  setRowStretch(index: number, value: number): void {
    // Look up the row sizer.
    let sizer = this._rowSizers[index];

    // Bail if the index is out of range.
    if (!sizer) {
      return;
    }

    // Clamp the value to the allowed range.
    value = Private.clampValue(value);

    // Bail if the stretch does not change.
    if (sizer.stretch === value) {
      return;
    }

    // Update the sizer stretch.
    sizer.stretch = value;

    // Schedule an update of the parent.
    if (this.parent) {
      this.parent.update();
    }
  }

  /**
   * Get the stretch factor for a specific column.
   *
   * @param index - The column index of interest.
   *
   * @returns The stretch factor for the column.
   *
   * #### Notes
   * This returns `-1` if the index is out of range.
   */
  columnStretch(index: number): number {
    let sizer = this._columnSizers[index];
    return sizer ? sizer.stretch : -1;
  }

  /**
   * Set the stretch factor for a specific column.
   *
   * @param index - The column index of interest.
   *
   * @param value - The stretch factor for the column.
   *
   * #### Notes
   * This is a no-op if the index is out of range.
   */
  setColumnStretch(index: number, value: number): void {
    // Look up the column sizer.
    let sizer = this._columnSizers[index];

    // Bail if the index is out of range.
    if (!sizer) {
      return;
    }

    // Clamp the value to the allowed range.
    value = Private.clampValue(value);

    // Bail if the stretch does not change.
    if (sizer.stretch === value) {
      return;
    }

    // Update the sizer stretch.
    sizer.stretch = value;

    // Schedule an update of the parent.
    if (this.parent) {
      this.parent.update();
    }
  }

  /**
   * Create an iterator over the widgets in the layout.
   *
   * @returns A new iterator over the widgets in the layout.
   */
  *[Symbol.iterator](): IterableIterator<Widget> {
    for (const item of this._items) {
      yield item.widget;
    }
  }

  /**
   * Add a widget to the grid layout.
   *
   * @param widget - The widget to add to the layout.
   *
   * #### Notes
   * If the widget is already contained in the layout, this is no-op.
   */
  addWidget(widget: Widget): void {
    // Look up the index for the widget.
    let i = ArrayExt.findFirstIndex(this._items, it => it.widget === widget);

    // Bail if the widget is already in the layout.
    if (i !== -1) {
      return;
    }

    // Add the widget to the layout.
    this._items.push(new LayoutItem(widget));

    // Attach the widget to the parent.
    if (this.parent) {
      this.attachWidget(widget);
    }
  }

  /**
   * Remove a widget from the grid layout.
   *
   * @param widget - The widget to remove from the layout.
   *
   * #### Notes
   * A widget is automatically removed from the layout when its `parent`
   * is set to `null`. This method should only be invoked directly when
   * removing a widget from a layout which has yet to be installed on a
   * parent widget.
   *
   * This method does *not* modify the widget's `parent`.
   */
  removeWidget(widget: Widget): void {
    // Look up the index for the widget.
    let i = ArrayExt.findFirstIndex(this._items, it => it.widget === widget);

    // Bail if the widget is not in the layout.
    if (i === -1) {
      return;
    }

    // Remove the widget from the layout.
    let item = ArrayExt.removeAt(this._items, i)!;

    // Detach the widget from the parent.
    if (this.parent) {
      this.detachWidget(widget);
    }

    // Dispose the layout item.
    item.dispose();
  }

  /**
   * Perform layout initialization which requires the parent widget.
   */
  protected init(): void {
    super.init();
    for (const widget of this) {
      this.attachWidget(widget);
    }
  }

  /**
   * Attach a widget to the parent's DOM node.
   *
   * @param widget - The widget to attach to the parent.
   */
  protected attachWidget(widget: Widget): void {
    // Send a `'before-attach'` message if the parent is attached.
    if (this.parent!.isAttached) {
      MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
    }

    // Add the widget's node to the parent.
    this.parent!.node.appendChild(widget.node);

    // Send an `'after-attach'` message if the parent is attached.
    if (this.parent!.isAttached) {
      MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
    }

    // Post a fit request for the parent widget.
    this.parent!.fit();
  }

  /**
   * Detach a widget from the parent's DOM node.
   *
   * @param widget - The widget to detach from the parent.
   */
  protected detachWidget(widget: Widget): void {
    // Send a `'before-detach'` message if the parent is attached.
    if (this.parent!.isAttached) {
      MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
    }

    // Remove the widget's node from the parent.
    this.parent!.node.removeChild(widget.node);

    // Send an `'after-detach'` message if the parent is attached.
    if (this.parent!.isAttached) {
      MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
    }

    // Post a fit request for the parent widget.
    this.parent!.fit();
  }

  /**
   * A message handler invoked on a `'before-show'` message.
   */
  protected onBeforeShow(msg: Message): void {
    super.onBeforeShow(msg);
    this.parent!.update();
  }

  /**
   * A message handler invoked on a `'before-attach'` message.
   */
  protected onBeforeAttach(msg: Message): void {
    super.onBeforeAttach(msg);
    this.parent!.fit();
  }

  /**
   * A message handler invoked on a `'child-shown'` message.
   */
  protected onChildShown(msg: Widget.ChildMessage): void {
    this.parent!.fit();
  }

  /**
   * A message handler invoked on a `'child-hidden'` message.
   */
  protected onChildHidden(msg: Widget.ChildMessage): void {
    this.parent!.fit();
  }

  /**
   * A message handler invoked on a `'resize'` message.
   */
  protected onResize(msg: Widget.ResizeMessage): void {
    if (this.parent!.isVisible) {
      this._update(msg.width, msg.height);
    }
  }

  /**
   * A message handler invoked on an `'update-request'` message.
   */
  protected onUpdateRequest(msg: Message): void {
    if (this.parent!.isVisible) {
      this._update(-1, -1);
    }
  }

  /**
   * A message handler invoked on a `'fit-request'` message.
   */
  protected onFitRequest(msg: Message): void {
    if (this.parent!.isAttached) {
      this._fit();
    }
  }

  /**
   * Fit the layout to the total size required by the widgets.
   */
  private _fit(): void {
    // Reset the min sizes of the sizers.
    for (let i = 0, n = this.rowCount; i < n; ++i) {
      this._rowSizers[i].minSize = 0;
    }
    for (let i = 0, n = this.columnCount; i < n; ++i) {
      this._columnSizers[i].minSize = 0;
    }

    // Filter for the visible layout items.
    let items = this._items.filter(it => !it.isHidden);

    // Fit the layout items.
    for (let i = 0, n = items.length; i < n; ++i) {
      items[i].fit();
    }

    // Get the max row and column index.
    let maxRow = this.rowCount - 1;
    let maxCol = this.columnCount - 1;

    // Sort the items by row span.
    items.sort(Private.rowSpanCmp);

    // Update the min sizes of the row sizers.
    for (let i = 0, n = items.length; i < n; ++i) {
      // Fetch the item.
      let item = items[i];

      // Get the row bounds for the item.
      let config = GridLayout.getCellConfig(item.widget);
      let r1 = Math.min(config.row, maxRow);
      let r2 = Math.min(config.row + config.rowSpan - 1, maxRow);

      // Distribute the minimum height to the sizers as needed.
      Private.distributeMin(this._rowSizers, r1, r2, item.minHeight);
    }

    // Sort the items by column span.
    items.sort(Private.columnSpanCmp);

    // Update the min sizes of the column sizers.
    for (let i = 0, n = items.length; i < n; ++i) {
      // Fetch the item.
      let item = items[i];

      // Get the column bounds for the item.
      let config = GridLayout.getCellConfig(item.widget);
      let c1 = Math.min(config.column, maxCol);
      let c2 = Math.min(config.column + config.columnSpan - 1, maxCol);

      // Distribute the minimum width to the sizers as needed.
      Private.distributeMin(this._columnSizers, c1, c2, item.minWidth);
    }

    // If no size constraint is needed, just update the parent.
    if (this.fitPolicy === 'set-no-constraint') {
      MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
      return;
    }

    // Set up the computed min size.
    let minH = maxRow * this._rowSpacing;
    let minW = maxCol * this._columnSpacing;

    // Add the sizer minimums to the computed min size.
    for (let i = 0, n = this.rowCount; i < n; ++i) {
      minH += this._rowSizers[i].minSize;
    }
    for (let i = 0, n = this.columnCount; i < n; ++i) {
      minW += this._columnSizers[i].minSize;
    }

    // Update the box sizing and add it to the computed min size.
    let box = (this._box = ElementExt.boxSizing(this.parent!.node));
    minW += box.horizontalSum;
    minH += box.verticalSum;

    // Update the parent's min size constraints.
    let style = this.parent!.node.style;
    style.minWidth = `${minW}px`;
    style.minHeight = `${minH}px`;

    // Set the dirty flag to ensure only a single update occurs.
    this._dirty = true;

    // Notify the ancestor that it should fit immediately. This may
    // cause a resize of the parent, fulfilling the required update.
    if (this.parent!.parent) {
      MessageLoop.sendMessage(this.parent!.parent!, Widget.Msg.FitRequest);
    }

    // If the dirty flag is still set, the parent was not resized.
    // Trigger the required update on the parent widget immediately.
    if (this._dirty) {
      MessageLoop.sendMessage(this.parent!, Widget.Msg.UpdateRequest);
    }
  }

  /**
   * Update the layout position and size of the widgets.
   *
   * The parent offset dimensions should be `-1` if unknown.
   */
  private _update(offsetWidth: number, offsetHeight: number): void {
    // Clear the dirty flag to indicate the update occurred.
    this._dirty = false;

    // Measure the parent if the offset dimensions are unknown.
    if (offsetWidth < 0) {
      offsetWidth = this.parent!.node.offsetWidth;
    }
    if (offsetHeight < 0) {
      offsetHeight = this.parent!.node.offsetHeight;
    }

    // Ensure the parent box sizing data is computed.
    if (!this._box) {
      this._box = ElementExt.boxSizing(this.parent!.node);
    }

    // Compute the layout area adjusted for border and padding.
    let top = this._box.paddingTop;
    let left = this._box.paddingLeft;
    let width = offsetWidth - this._box.horizontalSum;
    let height = offsetHeight - this._box.verticalSum;

    // Get the max row and column index.
    let maxRow = this.rowCount - 1;
    let maxCol = this.columnCount - 1;

    // Compute the total fixed row and column space.
    let fixedRowSpace = maxRow * this._rowSpacing;
    let fixedColSpace = maxCol * this._columnSpacing;

    // Distribute the available space to the box sizers.
    BoxEngine.calc(this._rowSizers, Math.max(0, height - fixedRowSpace));
    BoxEngine.calc(this._columnSizers, Math.max(0, width - fixedColSpace));

    // Update the row start positions.
    for (let i = 0, pos = top, n = this.rowCount; i < n; ++i) {
      this._rowStarts[i] = pos;
      pos += this._rowSizers[i].size + this._rowSpacing;
    }

    // Update the column start positions.
    for (let i = 0, pos = left, n = this.columnCount; i < n; ++i) {
      this._columnStarts[i] = pos;
      pos += this._columnSizers[i].size + this._columnSpacing;
    }

    // Update the geometry of the layout items.
    for (let i = 0, n = this._items.length; i < n; ++i) {
      // Fetch the item.
      let item = this._items[i];

      // Ignore hidden items.
      if (item.isHidden) {
        continue;
      }

      // Fetch the cell bounds for the widget.
      let config = GridLayout.getCellConfig(item.widget);
      let r1 = Math.min(config.row, maxRow);
      let c1 = Math.min(config.column, maxCol);
      let r2 = Math.min(config.row + config.rowSpan - 1, maxRow);
      let c2 = Math.min(config.column + config.columnSpan - 1, maxCol);

      // Compute the cell geometry.
      let x = this._columnStarts[c1];
      let y = this._rowStarts[r1];
      let w = this._columnStarts[c2] + this._columnSizers[c2].size - x;
      let h = this._rowStarts[r2] + this._rowSizers[r2].size - y;

      // Update the geometry of the layout item.
      item.update(x, y, w, h);
    }
  }

  private _dirty = false;
  private _rowSpacing = 4;
  private _columnSpacing = 4;
  private _items: LayoutItem[] = [];
  private _rowStarts: number[] = [];
  private _columnStarts: number[] = [];
  private _rowSizers: BoxSizer[] = [new BoxSizer()];
  private _columnSizers: BoxSizer[] = [new BoxSizer()];
  private _box: ElementExt.IBoxSizing | null = null;
}

/**
 * The namespace for the `GridLayout` class statics.
 */
export namespace GridLayout {
  /**
   * An options object for initializing a grid layout.
   */
  export interface IOptions extends Layout.IOptions {
    /**
     * The initial row count for the layout.
     *
     * The default is `1`.
     */
    rowCount?: number;

    /**
     * The initial column count for the layout.
     *
     * The default is `1`.
     */
    columnCount?: number;

    /**
     * The spacing between rows in the layout.
     *
     * The default is `4`.
     */
    rowSpacing?: number;

    /**
     * The spacing between columns in the layout.
     *
     * The default is `4`.
     */
    columnSpacing?: number;
  }

  /**
   * An object which holds the cell configuration for a widget.
   */
  export interface ICellConfig {
    /**
     * The row index for the widget.
     */
    readonly row: number;

    /**
     * The column index for the widget.
     */
    readonly column: number;

    /**
     * The row span for the widget.
     */
    readonly rowSpan: number;

    /**
     * The column span for the widget.
     */
    readonly columnSpan: number;
  }

  /**
   * Get the cell config for the given widget.
   *
   * @param widget - The widget of interest.
   *
   * @returns The cell config for the widget.
   */
  export function getCellConfig(widget: Widget): ICellConfig {
    return Private.cellConfigProperty.get(widget);
  }

  /**
   * Set the cell config for the given widget.
   *
   * @param widget - The widget of interest.
   *
   * @param value - The value for the cell config.
   */
  export function setCellConfig(
    widget: Widget,
    value: Partial<ICellConfig>
  ): void {
    Private.cellConfigProperty.set(widget, Private.normalizeConfig(value));
  }
}

/**
 * The namespace for the module implementation details.
 */
namespace Private {
  /**
   * The property descriptor for the widget cell config.
   */
  export const cellConfigProperty = new AttachedProperty<
    Widget,
    GridLayout.ICellConfig
  >({
    name: 'cellConfig',
    create: () => ({ row: 0, column: 0, rowSpan: 1, columnSpan: 1 }),
    changed: onChildCellConfigChanged
  });

  /**
   * Normalize a partial cell config object.
   */
  export function normalizeConfig(
    config: Partial<GridLayout.ICellConfig>
  ): GridLayout.ICellConfig {
    let row = Math.max(0, Math.floor(config.row || 0));
    let column = Math.max(0, Math.floor(config.column || 0));
    let rowSpan = Math.max(1, Math.floor(config.rowSpan || 0));
    let columnSpan = Math.max(1, Math.floor(config.columnSpan || 0));
    return { row, column, rowSpan, columnSpan };
  }

  /**
   * Clamp a value to an integer >= 0.
   */
  export function clampValue(value: number): number {
    return Math.max(0, Math.floor(value));
  }

  /**
   * A sort comparison function for row spans.
   */
  export function rowSpanCmp(a: LayoutItem, b: LayoutItem): number {
    let c1 = cellConfigProperty.get(a.widget);
    let c2 = cellConfigProperty.get(b.widget);
    return c1.rowSpan - c2.rowSpan;
  }

  /**
   * A sort comparison function for column spans.
   */
  export function columnSpanCmp(a: LayoutItem, b: LayoutItem): number {
    let c1 = cellConfigProperty.get(a.widget);
    let c2 = cellConfigProperty.get(b.widget);
    return c1.columnSpan - c2.columnSpan;
  }

  /**
   * Reallocate the box sizers for the given grid dimensions.
   */
  export function reallocSizers(sizers: BoxSizer[], count: number): void {
    // Coerce the count to the valid range.
    count = Math.max(1, Math.floor(count));

    // Add the missing sizers.
    while (sizers.length < count) {
      sizers.push(new BoxSizer());
    }

    // Remove the extra sizers.
    if (sizers.length > count) {
      sizers.length = count;
    }
  }

  /**
   * Distribute a min size constraint across a range of sizers.
   */
  export function distributeMin(
    sizers: BoxSizer[],
    i1: number,
    i2: number,
    minSize: number
  ): void {
    // Sanity check the indices.
    if (i2 < i1) {
      return;
    }

    // Handle the simple case of no cell span.
    if (i1 === i2) {
      let sizer = sizers[i1];
      sizer.minSize = Math.max(sizer.minSize, minSize);
      return;
    }

    // Compute the total current min size of the span.
    let totalMin = 0;
    for (let i = i1; i <= i2; ++i) {
      totalMin += sizers[i].minSize;
    }

    // Do nothing if the total is greater than the required.
    if (totalMin >= minSize) {
      return;
    }

    // Compute the portion of the space to allocate to each sizer.
    let portion = (minSize - totalMin) / (i2 - i1 + 1);

    // Add the portion to each sizer.
    for (let i = i1; i <= i2; ++i) {
      sizers[i].minSize += portion;
    }
  }

  /**
   * The change handler for the child cell config property.
   */
  function onChildCellConfigChanged(child: Widget): void {
    if (child.parent && child.parent.layout instanceof GridLayout) {
      child.parent.fit();
    }
  }
}
