/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import { $, addDisposableListener, append, getWindow, scheduleAtNextAnimationFrame } from '../../dom.js';
import { DomEmitter } from '../../event.js';
import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from '../sash/sash.js';
import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js';
import { pushToEnd, pushToStart, range } from '../../../common/arrays.js';
import { Color } from '../../../common/color.js';
import { Emitter, Event } from '../../../common/event.js';
import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from '../../../common/lifecycle.js';
import { clamp } from '../../../common/numbers.js';
import { Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js';
import * as types from '../../../common/types.js';
import './splitview.css';
export { Orientation } from '../sash/sash.js';

export interface ISplitViewStyles {
	readonly separatorBorder: Color;
}

const defaultStyles: ISplitViewStyles = {
	separatorBorder: Color.transparent
};

export const enum LayoutPriority {
	Normal,
	Low,
	High
}

/**
 * The interface to implement for views within a {@link SplitView}.
 *
 * An optional {@link TLayoutContext layout context type} may be used in order to
 * pass along layout contextual data from the {@link SplitView.layout} method down
 * to each view's {@link IView.layout} calls.
 */
export interface IView<TLayoutContext = undefined> {

	/**
	 * The DOM element for this view.
	 */
	readonly element: HTMLElement;

	/**
	 * A minimum size for this view.
	 *
	 * @remarks If none, set it to `0`.
	 */
	readonly minimumSize: number;

	/**
	 * A maximum size for this view.
	 *
	 * @remarks If none, set it to `Number.POSITIVE_INFINITY`.
	 */
	readonly maximumSize: number;

	/**
	 * The priority of the view when the {@link SplitView.resize layout} algorithm
	 * runs. Views with higher priority will be resized first.
	 *
	 * @remarks Only used when `proportionalLayout` is false.
	 */
	readonly priority?: LayoutPriority;

	/**
	 * If the {@link SplitView} supports {@link ISplitViewOptions.proportionalLayout proportional layout},
	 * this property allows for finer control over the proportional layout algorithm, per view.
	 *
	 * @defaultValue `true`
	 */
	readonly proportionalLayout?: boolean;

	/**
	 * Whether the view will snap whenever the user reaches its minimum size or
	 * attempts to grow it beyond the minimum size.
	 *
	 * @defaultValue `false`
	 */
	readonly snap?: boolean;

	/**
	 * View instances are supposed to fire the {@link IView.onDidChange} event whenever
	 * any of the constraint properties have changed:
	 *
	 * - {@link IView.minimumSize}
	 * - {@link IView.maximumSize}
	 * - {@link IView.priority}
	 * - {@link IView.snap}
	 *
	 * The SplitView will relayout whenever that happens. The event can optionally emit
	 * the view's preferred size for that relayout.
	 */
	readonly onDidChange: Event<number | undefined>;

	/**
	 * This will be called by the {@link SplitView} during layout. A view meant to
	 * pass along the layout information down to its descendants.
	 *
	 * @param size The size of this view, in pixels.
	 * @param offset The offset of this view, relative to the start of the {@link SplitView}.
	 * @param context The optional {@link IView layout context} passed to {@link SplitView.layout}.
	 */
	layout(size: number, offset: number, context: TLayoutContext | undefined): void;

	/**
	 * This will be called by the {@link SplitView} whenever this view is made
	 * visible or hidden.
	 *
	 * @param visible Whether the view becomes visible.
	 */
	setVisible?(visible: boolean): void;
}

/**
 * A descriptor for a {@link SplitView} instance.
 */
export interface ISplitViewDescriptor<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> {

	/**
	 * The layout size of the {@link SplitView}.
	 */
	readonly size: number;

	/**
	 * Descriptors for each {@link IView view}.
	 */
	readonly views: {

		/**
		 * Whether the {@link IView view} is visible.
		 *
		 * @defaultValue `true`
		 */
		readonly visible?: boolean;

		/**
		 * The size of the {@link IView view}.
		 *
		 * @defaultValue `true`
		 */
		readonly size: number;

		/**
		 * The size of the {@link IView view}.
		 *
		 * @defaultValue `true`
		 */
		readonly view: TView;
	}[];
}

export interface ISplitViewOptions<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> {

	/**
	 * Which axis the views align on.
	 *
	 * @defaultValue `Orientation.VERTICAL`
	 */
	readonly orientation?: Orientation;

	/**
	 * Styles overriding the {@link defaultStyles default ones}.
	 */
	readonly styles?: ISplitViewStyles;

	/**
	 * Make Alt-drag the default drag operation.
	 */
	readonly inverseAltBehavior?: boolean;

	/**
	 * Resize each view proportionally when resizing the SplitView.
	 *
	 * @defaultValue `true`
	 */
	readonly proportionalLayout?: boolean;

	/**
	 * An initial description of this {@link SplitView} instance, allowing
	 * to initialze all views within the ctor.
	 */
	readonly descriptor?: ISplitViewDescriptor<TLayoutContext, TView>;

	/**
	 * The scrollbar visibility setting for whenever the views within
	 * the {@link SplitView} overflow.
	 */
	readonly scrollbarVisibility?: ScrollbarVisibility;

	/**
	 * Override the orthogonal size of sashes.
	 */
	readonly getSashOrthogonalSize?: () => number;
}

interface ISashEvent {
	readonly sash: Sash;
	readonly start: number;
	readonly current: number;
	readonly alt: boolean;
}

type ViewItemSize = number | { cachedVisibleSize: number };

abstract class ViewItem<TLayoutContext, TView extends IView<TLayoutContext>> {

	private _size: number;
	set size(size: number) {
		this._size = size;
	}

	get size(): number {
		return this._size;
	}

	private _cachedVisibleSize: number | undefined = undefined;
	get cachedVisibleSize(): number | undefined { return this._cachedVisibleSize; }

	get visible(): boolean {
		return typeof this._cachedVisibleSize === 'undefined';
	}

	setVisible(visible: boolean, size?: number): void {
		if (visible === this.visible) {
			return;
		}

		if (visible) {
			this.size = clamp(this._cachedVisibleSize!, this.viewMinimumSize, this.viewMaximumSize);
			this._cachedVisibleSize = undefined;
		} else {
			this._cachedVisibleSize = typeof size === 'number' ? size : this.size;
			this.size = 0;
		}

		this.container.classList.toggle('visible', visible);

		try {
			this.view.setVisible?.(visible);
		} catch (e) {
			console.error('Splitview: Failed to set visible view');
			console.error(e);
		}
	}

	get minimumSize(): number { return this.visible ? this.view.minimumSize : 0; }
	get viewMinimumSize(): number { return this.view.minimumSize; }

	get maximumSize(): number { return this.visible ? this.view.maximumSize : 0; }
	get viewMaximumSize(): number { return this.view.maximumSize; }

	get priority(): LayoutPriority | undefined { return this.view.priority; }
	get proportionalLayout(): boolean { return this.view.proportionalLayout ?? true; }
	get snap(): boolean { return !!this.view.snap; }

	set enabled(enabled: boolean) {
		this.container.style.pointerEvents = enabled ? '' : 'none';
	}

	constructor(
		protected container: HTMLElement,
		readonly view: TView,
		size: ViewItemSize,
		private disposable: IDisposable
	) {
		if (typeof size === 'number') {
			this._size = size;
			this._cachedVisibleSize = undefined;
			container.classList.add('visible');
		} else {
			this._size = 0;
			this._cachedVisibleSize = size.cachedVisibleSize;
		}
	}

	layout(offset: number, layoutContext: TLayoutContext | undefined): void {
		this.layoutContainer(offset);

		try {
			this.view.layout(this.size, offset, layoutContext);
		} catch (e) {
			console.error('Splitview: Failed to layout view');
			console.error(e);
		}
	}

	abstract layoutContainer(offset: number): void;

	dispose(): void {
		this.disposable.dispose();
	}
}

class VerticalViewItem<TLayoutContext, TView extends IView<TLayoutContext>> extends ViewItem<TLayoutContext, TView> {

	layoutContainer(offset: number): void {
		this.container.style.top = `${offset}px`;
		this.container.style.height = `${this.size}px`;
	}
}

class HorizontalViewItem<TLayoutContext, TView extends IView<TLayoutContext>> extends ViewItem<TLayoutContext, TView> {

	layoutContainer(offset: number): void {
		this.container.style.left = `${offset}px`;
		this.container.style.width = `${this.size}px`;
	}
}

interface ISashItem {
	sash: Sash;
	disposable: IDisposable;
}

interface ISashDragSnapState {
	readonly index: number;
	readonly limitDelta: number;
	readonly size: number;
}

interface ISashDragState {
	index: number;
	start: number;
	current: number;
	sizes: number[];
	minDelta: number;
	maxDelta: number;
	alt: boolean;
	snapBefore: ISashDragSnapState | undefined;
	snapAfter: ISashDragSnapState | undefined;
	disposable: IDisposable;
}

enum State {
	Idle,
	Busy
}

/**
 * When adding or removing views, uniformly distribute the entire split view space among
 * all views.
 */
export type DistributeSizing = { type: 'distribute' };

/**
 * When adding a view, make space for it by reducing the size of another view,
 * indexed by the provided `index`.
 */
export type SplitSizing = { type: 'split'; index: number };

/**
 * When adding a view, use DistributeSizing when all pre-existing views are
 * distributed evenly, otherwise use SplitSizing.
 */
export type AutoSizing = { type: 'auto'; index: number };

/**
 * When adding or removing views, assume the view is invisible.
 */
export type InvisibleSizing = { type: 'invisible'; cachedVisibleSize: number };

/**
 * When adding or removing views, the sizing provides fine grained
 * control over how other views get resized.
 */
export type Sizing = DistributeSizing | SplitSizing | AutoSizing | InvisibleSizing;

export namespace Sizing {

	/**
	 * When adding or removing views, distribute the delta space among
	 * all other views.
	 */
	export const Distribute: DistributeSizing = { type: 'distribute' };

	/**
	 * When adding or removing views, split the delta space with another
	 * specific view, indexed by the provided `index`.
	 */
	export function Split(index: number): SplitSizing { return { type: 'split', index }; }

	/**
	 * When adding a view, use DistributeSizing when all pre-existing views are
	 * distributed evenly, otherwise use SplitSizing.
	 */
	export function Auto(index: number): AutoSizing { return { type: 'auto', index }; }

	/**
	 * When adding or removing views, assume the view is invisible.
	 */
	export function Invisible(cachedVisibleSize: number): InvisibleSizing { return { type: 'invisible', cachedVisibleSize }; }
}

/**
 * The {@link SplitView} is the UI component which implements a one dimensional
 * flex-like layout algorithm for a collection of {@link IView} instances, which
 * are essentially HTMLElement instances with the following size constraints:
 *
 * - {@link IView.minimumSize}
 * - {@link IView.maximumSize}
 * - {@link IView.priority}
 * - {@link IView.snap}
 *
 * In case the SplitView doesn't have enough size to fit all views, it will overflow
 * its content with a scrollbar.
 *
 * In between each pair of views there will be a {@link Sash} allowing the user
 * to resize the views, making sure the constraints are respected.
 *
 * An optional {@link TLayoutContext layout context type} may be used in order to
 * pass along layout contextual data from the {@link SplitView.layout} method down
 * to each view's {@link IView.layout} calls.
 *
 * Features:
 * - Flex-like layout algorithm
 * - Snap support
 * - Orthogonal sash support, for corner sashes
 * - View hide/show support
 * - View swap/move support
 * - Alt key modifier behavior, macOS style
 */
export class SplitView<TLayoutContext = undefined, TView extends IView<TLayoutContext> = IView<TLayoutContext>> extends Disposable {

	/**
	 * This {@link SplitView}'s orientation.
	 */
	readonly orientation: Orientation;

	/**
	 * The DOM element representing this {@link SplitView}.
	 */
	readonly el: HTMLElement;

	private sashContainer: HTMLElement;
	private viewContainer: HTMLElement;
	private scrollable: Scrollable;
	private scrollableElement: SmoothScrollableElement;
	private size = 0;
	private layoutContext: TLayoutContext | undefined;
	private _contentSize = 0;
	private proportions: (number | undefined)[] | undefined = undefined;
	private viewItems: ViewItem<TLayoutContext, TView>[] = [];
	sashItems: ISashItem[] = []; // used in tests
	private sashDragState: ISashDragState | undefined;
	private state: State = State.Idle;
	private inverseAltBehavior: boolean;
	private proportionalLayout: boolean;
	private readonly getSashOrthogonalSize: { (): number } | undefined;

	private _onDidSashChange = this._register(new Emitter<number>());
	private _onDidSashReset = this._register(new Emitter<number>());
	private _orthogonalStartSash: Sash | undefined;
	private _orthogonalEndSash: Sash | undefined;
	private _startSnappingEnabled = true;
	private _endSnappingEnabled = true;

	/**
	 * The sum of all views' sizes.
	 */
	get contentSize(): number { return this._contentSize; }

	/**
	 * Fires whenever the user resizes a {@link Sash sash}.
	 */
	readonly onDidSashChange = this._onDidSashChange.event;

	/**
	 * Fires whenever the user double clicks a {@link Sash sash}.
	 */
	readonly onDidSashReset = this._onDidSashReset.event;

	/**
	 * Fires whenever the split view is scrolled.
	 */
	readonly onDidScroll: Event<ScrollEvent>;

	/**
	 * The amount of views in this {@link SplitView}.
	 */
	get length(): number {
		return this.viewItems.length;
	}

	/**
	 * The minimum size of this {@link SplitView}.
	 */
	get minimumSize(): number {
		return this.viewItems.reduce((r, item) => r + item.minimumSize, 0);
	}

	/**
	 * The maximum size of this {@link SplitView}.
	 */
	get maximumSize(): number {
		return this.length === 0 ? Number.POSITIVE_INFINITY : this.viewItems.reduce((r, item) => r + item.maximumSize, 0);
	}

	get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; }
	get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; }
	get startSnappingEnabled(): boolean { return this._startSnappingEnabled; }
	get endSnappingEnabled(): boolean { return this._endSnappingEnabled; }

	/**
	 * A reference to a sash, perpendicular to all sashes in this {@link SplitView},
	 * located at the left- or top-most side of the SplitView.
	 * Corner sashes will be created automatically at the intersections.
	 */
	set orthogonalStartSash(sash: Sash | undefined) {
		for (const sashItem of this.sashItems) {
			sashItem.sash.orthogonalStartSash = sash;
		}

		this._orthogonalStartSash = sash;
	}

	/**
	 * A reference to a sash, perpendicular to all sashes in this {@link SplitView},
	 * located at the right- or bottom-most side of the SplitView.
	 * Corner sashes will be created automatically at the intersections.
	 */
	set orthogonalEndSash(sash: Sash | undefined) {
		for (const sashItem of this.sashItems) {
			sashItem.sash.orthogonalEndSash = sash;
		}

		this._orthogonalEndSash = sash;
	}

	/**
	 * The internal sashes within this {@link SplitView}.
	 */
	get sashes(): readonly Sash[] {
		return this.sashItems.map(s => s.sash);
	}

	/**
	 * Enable/disable snapping at the beginning of this {@link SplitView}.
	 */
	set startSnappingEnabled(startSnappingEnabled: boolean) {
		if (this._startSnappingEnabled === startSnappingEnabled) {
			return;
		}

		this._startSnappingEnabled = startSnappingEnabled;
		this.updateSashEnablement();
	}

	/**
	 * Enable/disable snapping at the end of this {@link SplitView}.
	 */
	set endSnappingEnabled(endSnappingEnabled: boolean) {
		if (this._endSnappingEnabled === endSnappingEnabled) {
			return;
		}

		this._endSnappingEnabled = endSnappingEnabled;
		this.updateSashEnablement();
	}

	/**
	 * Create a new {@link SplitView} instance.
	 */
	constructor(container: HTMLElement, options: ISplitViewOptions<TLayoutContext, TView> = {}) {
		super();

		this.orientation = options.orientation ?? Orientation.VERTICAL;
		this.inverseAltBehavior = options.inverseAltBehavior ?? false;
		this.proportionalLayout = options.proportionalLayout ?? true;
		this.getSashOrthogonalSize = options.getSashOrthogonalSize;

		this.el = document.createElement('div');
		this.el.classList.add('monaco-split-view2');
		this.el.classList.add(this.orientation === Orientation.VERTICAL ? 'vertical' : 'horizontal');
		container.appendChild(this.el);

		this.sashContainer = append(this.el, $('.sash-container'));
		this.viewContainer = $('.split-view-container');

		this.scrollable = this._register(new Scrollable({
			forceIntegerValues: true,
			smoothScrollDuration: 125,
			scheduleAtNextAnimationFrame: callback => scheduleAtNextAnimationFrame(getWindow(this.el), callback),
		}));
		this.scrollableElement = this._register(new SmoothScrollableElement(this.viewContainer, {
			vertical: this.orientation === Orientation.VERTICAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden,
			horizontal: this.orientation === Orientation.HORIZONTAL ? (options.scrollbarVisibility ?? ScrollbarVisibility.Auto) : ScrollbarVisibility.Hidden
		}, this.scrollable));

		// https://github.com/microsoft/vscode/issues/157737
		const onDidScrollViewContainer = this._register(new DomEmitter(this.viewContainer, 'scroll')).event;
		this._register(onDidScrollViewContainer(_ => {
			const position = this.scrollableElement.getScrollPosition();
			const scrollLeft = Math.abs(this.viewContainer.scrollLeft - position.scrollLeft) <= 1 ? undefined : this.viewContainer.scrollLeft;
			const scrollTop = Math.abs(this.viewContainer.scrollTop - position.scrollTop) <= 1 ? undefined : this.viewContainer.scrollTop;

			if (scrollLeft !== undefined || scrollTop !== undefined) {
				this.scrollableElement.setScrollPosition({ scrollLeft, scrollTop });
			}
		}));

		this.onDidScroll = this.scrollableElement.onScroll;
		this._register(this.onDidScroll(e => {
			if (e.scrollTopChanged) {
				this.viewContainer.scrollTop = e.scrollTop;
			}

			if (e.scrollLeftChanged) {
				this.viewContainer.scrollLeft = e.scrollLeft;
			}
		}));

		append(this.el, this.scrollableElement.getDomNode());

		this.style(options.styles || defaultStyles);

		// We have an existing set of view, add them now
		if (options.descriptor) {
			this.size = options.descriptor.size;
			options.descriptor.views.forEach((viewDescriptor, index) => {
				const sizing = types.isUndefined(viewDescriptor.visible) || viewDescriptor.visible ? viewDescriptor.size : { type: 'invisible', cachedVisibleSize: viewDescriptor.size } satisfies InvisibleSizing;

				const view = viewDescriptor.view;
				this.doAddView(view, sizing, index, true);
			});

			// Initialize content size and proportions for first layout
			this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
			this.saveProportions();
		}
	}

	style(styles: ISplitViewStyles): void {
		if (styles.separatorBorder.isTransparent()) {
			this.el.classList.remove('separator-border');
			this.el.style.removeProperty('--separator-border');
		} else {
			this.el.classList.add('separator-border');
			this.el.style.setProperty('--separator-border', styles.separatorBorder.toString());
		}
	}

	/**
	 * Add a {@link IView view} to this {@link SplitView}.
	 *
	 * @param view The view to add.
	 * @param size Either a fixed size, or a dynamic {@link Sizing} strategy.
	 * @param index The index to insert the view on.
	 * @param skipLayout Whether layout should be skipped.
	 */
	addView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void {
		this.doAddView(view, size, index, skipLayout);
	}

	/**
	 * Remove a {@link IView view} from this {@link SplitView}.
	 *
	 * @param index The index where the {@link IView view} is located.
	 * @param sizing Whether to distribute other {@link IView view}'s sizes.
	 */
	removeView(index: number, sizing?: Sizing): TView {
		if (index < 0 || index >= this.viewItems.length) {
			throw new Error('Index out of bounds');
		}

		if (this.state !== State.Idle) {
			throw new Error('Cant modify splitview');
		}

		this.state = State.Busy;

		try {
			if (sizing?.type === 'auto') {
				if (this.areViewsDistributed()) {
					sizing = { type: 'distribute' };
				} else {
					sizing = { type: 'split', index: sizing.index };
				}
			}

			// Save referene view, in case of `split` sizing
			const referenceViewItem = sizing?.type === 'split' ? this.viewItems[sizing.index] : undefined;

			// Remove view
			const viewItemToRemove = this.viewItems.splice(index, 1)[0];

			// Resize reference view, in case of `split` sizing
			if (referenceViewItem) {
				referenceViewItem.size += viewItemToRemove.size;
			}

			// Remove sash
			if (this.viewItems.length >= 1) {
				const sashIndex = Math.max(index - 1, 0);
				const sashItem = this.sashItems.splice(sashIndex, 1)[0];
				sashItem.disposable.dispose();
			}

			this.relayout();

			if (sizing?.type === 'distribute') {
				this.distributeViewSizes();
			}

			const result = viewItemToRemove.view;
			viewItemToRemove.dispose();
			return result;

		} finally {
			this.state = State.Idle;
		}
	}

	removeAllViews(): TView[] {
		if (this.state !== State.Idle) {
			throw new Error('Cant modify splitview');
		}

		this.state = State.Busy;

		try {
			const viewItems = this.viewItems.splice(0, this.viewItems.length);

			for (const viewItem of viewItems) {
				viewItem.dispose();
			}

			const sashItems = this.sashItems.splice(0, this.sashItems.length);

			for (const sashItem of sashItems) {
				sashItem.disposable.dispose();
			}

			this.relayout();
			return viewItems.map(i => i.view);

		} finally {
			this.state = State.Idle;
		}
	}

	/**
	 * Move a {@link IView view} to a different index.
	 *
	 * @param from The source index.
	 * @param to The target index.
	 */
	moveView(from: number, to: number): void {
		if (this.state !== State.Idle) {
			throw new Error('Cant modify splitview');
		}

		const cachedVisibleSize = this.getViewCachedVisibleSize(from);
		const sizing = typeof cachedVisibleSize === 'undefined' ? this.getViewSize(from) : Sizing.Invisible(cachedVisibleSize);
		const view = this.removeView(from);
		this.addView(view, sizing, to);
	}


	/**
	 * Swap two {@link IView views}.
	 *
	 * @param from The source index.
	 * @param to The target index.
	 */
	swapViews(from: number, to: number): void {
		if (this.state !== State.Idle) {
			throw new Error('Cant modify splitview');
		}

		if (from > to) {
			return this.swapViews(to, from);
		}

		const fromSize = this.getViewSize(from);
		const toSize = this.getViewSize(to);
		const toView = this.removeView(to);
		const fromView = this.removeView(from);

		this.addView(toView, fromSize, from);
		this.addView(fromView, toSize, to);
	}

	/**
	 * Returns whether the {@link IView view} is visible.
	 *
	 * @param index The {@link IView view} index.
	 */
	isViewVisible(index: number): boolean {
		if (index < 0 || index >= this.viewItems.length) {
			throw new Error('Index out of bounds');
		}

		const viewItem = this.viewItems[index];
		return viewItem.visible;
	}

	/**
	 * Set a {@link IView view}'s visibility.
	 *
	 * @param index The {@link IView view} index.
	 * @param visible Whether the {@link IView view} should be visible.
	 */
	setViewVisible(index: number, visible: boolean): void {
		if (index < 0 || index >= this.viewItems.length) {
			throw new Error('Index out of bounds');
		}

		const viewItem = this.viewItems[index];
		viewItem.setVisible(visible);

		this.distributeEmptySpace(index);
		this.layoutViews();
		this.saveProportions();
	}

	/**
	 * Returns the {@link IView view}'s size previously to being hidden.
	 *
	 * @param index The {@link IView view} index.
	 */
	getViewCachedVisibleSize(index: number): number | undefined {
		if (index < 0 || index >= this.viewItems.length) {
			throw new Error('Index out of bounds');
		}

		const viewItem = this.viewItems[index];
		return viewItem.cachedVisibleSize;
	}

	/**
	 * Layout the {@link SplitView}.
	 *
	 * @param size The entire size of the {@link SplitView}.
	 * @param layoutContext An optional layout context to pass along to {@link IView views}.
	 */
	layout(size: number, layoutContext?: TLayoutContext): void {
		const previousSize = Math.max(this.size, this._contentSize);
		this.size = size;
		this.layoutContext = layoutContext;

		if (!this.proportions) {
			const indexes = range(this.viewItems.length);
			const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);
			const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);

			this.resize(this.viewItems.length - 1, size - previousSize, undefined, lowPriorityIndexes, highPriorityIndexes);
		} else {
			let total = 0;

			for (let i = 0; i < this.viewItems.length; i++) {
				const item = this.viewItems[i];
				const proportion = this.proportions[i];

				if (typeof proportion === 'number') {
					total += proportion;
				} else {
					size -= item.size;
				}
			}

			for (let i = 0; i < this.viewItems.length; i++) {
				const item = this.viewItems[i];
				const proportion = this.proportions[i];

				if (typeof proportion === 'number' && total > 0) {
					item.size = clamp(Math.round(proportion * size / total), item.minimumSize, item.maximumSize);
				}
			}
		}

		this.distributeEmptySpace();
		this.layoutViews();
	}

	private saveProportions(): void {
		if (this.proportionalLayout && this._contentSize > 0) {
			this.proportions = this.viewItems.map(v => v.proportionalLayout && v.visible ? v.size / this._contentSize : undefined);
		}
	}

	private onSashStart({ sash, start, alt }: ISashEvent): void {
		for (const item of this.viewItems) {
			item.enabled = false;
		}

		const index = this.sashItems.findIndex(item => item.sash === sash);

		// This way, we can press Alt while we resize a sash, macOS style!
		const disposable = combinedDisposable(
			addDisposableListener(this.el.ownerDocument.body, 'keydown', e => resetSashDragState(this.sashDragState!.current, e.altKey)),
			addDisposableListener(this.el.ownerDocument.body, 'keyup', () => resetSashDragState(this.sashDragState!.current, false))
		);

		const resetSashDragState = (start: number, alt: boolean) => {
			const sizes = this.viewItems.map(i => i.size);
			let minDelta = Number.NEGATIVE_INFINITY;
			let maxDelta = Number.POSITIVE_INFINITY;

			if (this.inverseAltBehavior) {
				alt = !alt;
			}

			if (alt) {
				// When we're using the last sash with Alt, we're resizing
				// the view to the left/up, instead of right/down as usual
				// Thus, we must do the inverse of the usual
				const isLastSash = index === this.sashItems.length - 1;

				if (isLastSash) {
					const viewItem = this.viewItems[index];
					minDelta = (viewItem.minimumSize - viewItem.size) / 2;
					maxDelta = (viewItem.maximumSize - viewItem.size) / 2;
				} else {
					const viewItem = this.viewItems[index + 1];
					minDelta = (viewItem.size - viewItem.maximumSize) / 2;
					maxDelta = (viewItem.size - viewItem.minimumSize) / 2;
				}
			}

			let snapBefore: ISashDragSnapState | undefined;
			let snapAfter: ISashDragSnapState | undefined;

			if (!alt) {
				const upIndexes = range(index, -1);
				const downIndexes = range(index + 1, this.viewItems.length);
				const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0);
				const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].viewMaximumSize - sizes[i]), 0);
				const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0);
				const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].viewMaximumSize), 0);
				const minDelta = Math.max(minDeltaUp, minDeltaDown);
				const maxDelta = Math.min(maxDeltaDown, maxDeltaUp);
				const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);
				const snapAfterIndex = this.findFirstSnapIndex(downIndexes);

				if (typeof snapBeforeIndex === 'number') {
					const viewItem = this.viewItems[snapBeforeIndex];
					const halfSize = Math.floor(viewItem.viewMinimumSize / 2);

					snapBefore = {
						index: snapBeforeIndex,
						limitDelta: viewItem.visible ? minDelta - halfSize : minDelta + halfSize,
						size: viewItem.size
					};
				}

				if (typeof snapAfterIndex === 'number') {
					const viewItem = this.viewItems[snapAfterIndex];
					const halfSize = Math.floor(viewItem.viewMinimumSize / 2);

					snapAfter = {
						index: snapAfterIndex,
						limitDelta: viewItem.visible ? maxDelta + halfSize : maxDelta - halfSize,
						size: viewItem.size
					};
				}
			}

			this.sashDragState = { start, current: start, index, sizes, minDelta, maxDelta, alt, snapBefore, snapAfter, disposable };
		};

		resetSashDragState(start, alt);
	}

	private onSashChange({ current }: ISashEvent): void {
		const { index, start, sizes, alt, minDelta, maxDelta, snapBefore, snapAfter } = this.sashDragState!;
		this.sashDragState!.current = current;

		const delta = current - start;
		const newDelta = this.resize(index, delta, sizes, undefined, undefined, minDelta, maxDelta, snapBefore, snapAfter);

		if (alt) {
			const isLastSash = index === this.sashItems.length - 1;
			const newSizes = this.viewItems.map(i => i.size);
			const viewItemIndex = isLastSash ? index : index + 1;
			const viewItem = this.viewItems[viewItemIndex];
			const newMinDelta = viewItem.size - viewItem.maximumSize;
			const newMaxDelta = viewItem.size - viewItem.minimumSize;
			const resizeIndex = isLastSash ? index - 1 : index + 1;

			this.resize(resizeIndex, -newDelta, newSizes, undefined, undefined, newMinDelta, newMaxDelta);
		}

		this.distributeEmptySpace();
		this.layoutViews();
	}

	private onSashEnd(index: number): void {
		this._onDidSashChange.fire(index);
		this.sashDragState!.disposable.dispose();
		this.saveProportions();

		for (const item of this.viewItems) {
			item.enabled = true;
		}
	}

	private onViewChange(item: ViewItem<TLayoutContext, TView>, size: number | undefined): void {
		const index = this.viewItems.indexOf(item);

		if (index < 0 || index >= this.viewItems.length) {
			return;
		}

		size = typeof size === 'number' ? size : item.size;
		size = clamp(size, item.minimumSize, item.maximumSize);

		if (this.inverseAltBehavior && index > 0) {
			// In this case, we want the view to grow or shrink both sides equally
			// so we just resize the "left" side by half and let `resize` do the clamping magic
			this.resize(index - 1, Math.floor((item.size - size) / 2));
			this.distributeEmptySpace();
			this.layoutViews();
		} else {
			item.size = size;
			this.relayout([index], undefined);
		}
	}

	/**
	 * Resize a {@link IView view} within the {@link SplitView}.
	 *
	 * @param index The {@link IView view} index.
	 * @param size The {@link IView view} size.
	 */
	resizeView(index: number, size: number): void {
		if (index < 0 || index >= this.viewItems.length) {
			return;
		}

		if (this.state !== State.Idle) {
			throw new Error('Cant modify splitview');
		}

		this.state = State.Busy;

		try {
			const indexes = range(this.viewItems.length).filter(i => i !== index);
			const lowPriorityIndexes = [...indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low), index];
			const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);

			const item = this.viewItems[index];
			size = Math.round(size);
			size = clamp(size, item.minimumSize, Math.min(item.maximumSize, this.size));

			item.size = size;
			this.relayout(lowPriorityIndexes, highPriorityIndexes);
		} finally {
			this.state = State.Idle;
		}
	}

	/**
	 * Returns whether all other {@link IView views} are at their minimum size.
	 */
	isViewExpanded(index: number): boolean {
		if (index < 0 || index >= this.viewItems.length) {
			return false;
		}

		for (const item of this.viewItems) {
			if (item !== this.viewItems[index] && item.size > item.minimumSize) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Distribute the entire {@link SplitView} size among all {@link IView views}.
	 */
	distributeViewSizes(): void {
		const flexibleViewItems: ViewItem<TLayoutContext, TView>[] = [];
		let flexibleSize = 0;

		for (const item of this.viewItems) {
			if (item.maximumSize - item.minimumSize > 0) {
				flexibleViewItems.push(item);
				flexibleSize += item.size;
			}
		}

		const size = Math.floor(flexibleSize / flexibleViewItems.length);

		for (const item of flexibleViewItems) {
			item.size = clamp(size, item.minimumSize, item.maximumSize);
		}

		const indexes = range(this.viewItems.length);
		const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);
		const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);

		this.relayout(lowPriorityIndexes, highPriorityIndexes);
	}

	/**
	 * Returns the size of a {@link IView view}.
	 */
	getViewSize(index: number): number {
		if (index < 0 || index >= this.viewItems.length) {
			return -1;
		}

		return this.viewItems[index].size;
	}

	private doAddView(view: TView, size: number | Sizing, index = this.viewItems.length, skipLayout?: boolean): void {
		if (this.state !== State.Idle) {
			throw new Error('Cant modify splitview');
		}

		this.state = State.Busy;

		try {
			// Add view
			const container = $('.split-view-view');

			if (index === this.viewItems.length) {
				this.viewContainer.appendChild(container);
			} else {
				this.viewContainer.insertBefore(container, this.viewContainer.children.item(index));
			}

			const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size));
			const containerDisposable = toDisposable(() => container.remove());
			const disposable = combinedDisposable(onChangeDisposable, containerDisposable);

			let viewSize: ViewItemSize;

			if (typeof size === 'number') {
				viewSize = size;
			} else {
				if (size.type === 'auto') {
					if (this.areViewsDistributed()) {
						size = { type: 'distribute' };
					} else {
						size = { type: 'split', index: size.index };
					}
				}

				if (size.type === 'split') {
					viewSize = this.getViewSize(size.index) / 2;
				} else if (size.type === 'invisible') {
					viewSize = { cachedVisibleSize: size.cachedVisibleSize };
				} else {
					viewSize = view.minimumSize;
				}
			}

			const item = this.orientation === Orientation.VERTICAL
				? new VerticalViewItem(container, view, viewSize, disposable)
				: new HorizontalViewItem(container, view, viewSize, disposable);

			this.viewItems.splice(index, 0, item);

			// Add sash
			if (this.viewItems.length > 1) {
				const opts = { orthogonalStartSash: this.orthogonalStartSash, orthogonalEndSash: this.orthogonalEndSash };

				const sash = this.orientation === Orientation.VERTICAL
					? new Sash(this.sashContainer, { getHorizontalSashTop: s => this.getSashPosition(s), getHorizontalSashWidth: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.HORIZONTAL })
					: new Sash(this.sashContainer, { getVerticalSashLeft: s => this.getSashPosition(s), getVerticalSashHeight: this.getSashOrthogonalSize }, { ...opts, orientation: Orientation.VERTICAL });

				const sashEventMapper = this.orientation === Orientation.VERTICAL
					? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey })
					: (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey });

				const onStart = Event.map(sash.onDidStart, sashEventMapper);
				const onStartDisposable = onStart(this.onSashStart, this);
				const onChange = Event.map(sash.onDidChange, sashEventMapper);
				const onChangeDisposable = onChange(this.onSashChange, this);
				const onEnd = Event.map(sash.onDidEnd, () => this.sashItems.findIndex(item => item.sash === sash));
				const onEndDisposable = onEnd(this.onSashEnd, this);

				const onDidResetDisposable = sash.onDidReset(() => {
					const index = this.sashItems.findIndex(item => item.sash === sash);
					const upIndexes = range(index, -1);
					const downIndexes = range(index + 1, this.viewItems.length);
					const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);
					const snapAfterIndex = this.findFirstSnapIndex(downIndexes);

					if (typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible) {
						return;
					}

					if (typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible) {
						return;
					}

					this._onDidSashReset.fire(index);
				});

				const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, onEndDisposable, onDidResetDisposable, sash);
				const sashItem: ISashItem = { sash, disposable };

				this.sashItems.splice(index - 1, 0, sashItem);
			}

			container.appendChild(view.element);

			let highPriorityIndexes: number[] | undefined;

			if (typeof size !== 'number' && size.type === 'split') {
				highPriorityIndexes = [size.index];
			}

			if (!skipLayout) {
				this.relayout([index], highPriorityIndexes);
			}


			if (!skipLayout && typeof size !== 'number' && size.type === 'distribute') {
				this.distributeViewSizes();
			}

		} finally {
			this.state = State.Idle;
		}
	}

	private relayout(lowPriorityIndexes?: number[], highPriorityIndexes?: number[]): void {
		const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);

		this.resize(this.viewItems.length - 1, this.size - contentSize, undefined, lowPriorityIndexes, highPriorityIndexes);
		this.distributeEmptySpace();
		this.layoutViews();
		this.saveProportions();
	}

	private resize(
		index: number,
		delta: number,
		sizes = this.viewItems.map(i => i.size),
		lowPriorityIndexes?: number[],
		highPriorityIndexes?: number[],
		overloadMinDelta: number = Number.NEGATIVE_INFINITY,
		overloadMaxDelta: number = Number.POSITIVE_INFINITY,
		snapBefore?: ISashDragSnapState,
		snapAfter?: ISashDragSnapState
	): number {
		if (index < 0 || index >= this.viewItems.length) {
			return 0;
		}

		const upIndexes = range(index, -1);
		const downIndexes = range(index + 1, this.viewItems.length);

		if (highPriorityIndexes) {
			for (const index of highPriorityIndexes) {
				pushToStart(upIndexes, index);
				pushToStart(downIndexes, index);
			}
		}

		if (lowPriorityIndexes) {
			for (const index of lowPriorityIndexes) {
				pushToEnd(upIndexes, index);
				pushToEnd(downIndexes, index);
			}
		}

		const upItems = upIndexes.map(i => this.viewItems[i]);
		const upSizes = upIndexes.map(i => sizes[i]);

		const downItems = downIndexes.map(i => this.viewItems[i]);
		const downSizes = downIndexes.map(i => sizes[i]);

		const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].minimumSize - sizes[i]), 0);
		const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].maximumSize - sizes[i]), 0);
		const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].minimumSize), 0);
		const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].maximumSize), 0);
		const minDelta = Math.max(minDeltaUp, minDeltaDown, overloadMinDelta);
		const maxDelta = Math.min(maxDeltaDown, maxDeltaUp, overloadMaxDelta);

		let snapped = false;

		if (snapBefore) {
			const snapView = this.viewItems[snapBefore.index];
			const visible = delta >= snapBefore.limitDelta;
			snapped = visible !== snapView.visible;
			snapView.setVisible(visible, snapBefore.size);
		}

		if (!snapped && snapAfter) {
			const snapView = this.viewItems[snapAfter.index];
			const visible = delta < snapAfter.limitDelta;
			snapped = visible !== snapView.visible;
			snapView.setVisible(visible, snapAfter.size);
		}

		if (snapped) {
			return this.resize(index, delta, sizes, lowPriorityIndexes, highPriorityIndexes, overloadMinDelta, overloadMaxDelta);
		}

		delta = clamp(delta, minDelta, maxDelta);

		for (let i = 0, deltaUp = delta; i < upItems.length; i++) {
			const item = upItems[i];
			const size = clamp(upSizes[i] + deltaUp, item.minimumSize, item.maximumSize);
			const viewDelta = size - upSizes[i];

			deltaUp -= viewDelta;
			item.size = size;
		}

		for (let i = 0, deltaDown = delta; i < downItems.length; i++) {
			const item = downItems[i];
			const size = clamp(downSizes[i] - deltaDown, item.minimumSize, item.maximumSize);
			const viewDelta = size - downSizes[i];

			deltaDown += viewDelta;
			item.size = size;
		}

		return delta;
	}

	private distributeEmptySpace(lowPriorityIndex?: number): void {
		const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
		let emptyDelta = this.size - contentSize;

		const indexes = range(this.viewItems.length - 1, -1);
		const lowPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low);
		const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High);

		for (const index of highPriorityIndexes) {
			pushToStart(indexes, index);
		}

		for (const index of lowPriorityIndexes) {
			pushToEnd(indexes, index);
		}

		if (typeof lowPriorityIndex === 'number') {
			pushToEnd(indexes, lowPriorityIndex);
		}

		for (let i = 0; emptyDelta !== 0 && i < indexes.length; i++) {
			const item = this.viewItems[indexes[i]];
			const size = clamp(item.size + emptyDelta, item.minimumSize, item.maximumSize);
			const viewDelta = size - item.size;

			emptyDelta -= viewDelta;
			item.size = size;
		}
	}

	private layoutViews(): void {
		// Save new content size
		this._contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);

		// Layout views
		let offset = 0;

		for (const viewItem of this.viewItems) {
			viewItem.layout(offset, this.layoutContext);
			offset += viewItem.size;
		}

		// Layout sashes
		this.sashItems.forEach(item => item.sash.layout());
		this.updateSashEnablement();
		this.updateScrollableElement();
	}

	private updateScrollableElement(): void {
		if (this.orientation === Orientation.VERTICAL) {
			this.scrollableElement.setScrollDimensions({
				height: this.size,
				scrollHeight: this._contentSize
			});
		} else {
			this.scrollableElement.setScrollDimensions({
				width: this.size,
				scrollWidth: this._contentSize
			});
		}
	}

	private updateSashEnablement(): void {
		let previous = false;
		const collapsesDown = this.viewItems.map(i => previous = (i.size - i.minimumSize > 0) || previous);

		previous = false;
		const expandsDown = this.viewItems.map(i => previous = (i.maximumSize - i.size > 0) || previous);

		const reverseViews = [...this.viewItems].reverse();
		previous = false;
		const collapsesUp = reverseViews.map(i => previous = (i.size - i.minimumSize > 0) || previous).reverse();

		previous = false;
		const expandsUp = reverseViews.map(i => previous = (i.maximumSize - i.size > 0) || previous).reverse();

		let position = 0;
		for (let index = 0; index < this.sashItems.length; index++) {
			const { sash } = this.sashItems[index];
			const viewItem = this.viewItems[index];
			position += viewItem.size;

			const min = !(collapsesDown[index] && expandsUp[index + 1]);
			const max = !(expandsDown[index] && collapsesUp[index + 1]);

			if (min && max) {
				const upIndexes = range(index, -1);
				const downIndexes = range(index + 1, this.viewItems.length);
				const snapBeforeIndex = this.findFirstSnapIndex(upIndexes);
				const snapAfterIndex = this.findFirstSnapIndex(downIndexes);

				const snappedBefore = typeof snapBeforeIndex === 'number' && !this.viewItems[snapBeforeIndex].visible;
				const snappedAfter = typeof snapAfterIndex === 'number' && !this.viewItems[snapAfterIndex].visible;

				if (snappedBefore && collapsesUp[index] && (position > 0 || this.startSnappingEnabled)) {
					sash.state = SashState.AtMinimum;
				} else if (snappedAfter && collapsesDown[index] && (position < this._contentSize || this.endSnappingEnabled)) {
					sash.state = SashState.AtMaximum;
				} else {
					sash.state = SashState.Disabled;
				}
			} else if (min && !max) {
				sash.state = SashState.AtMinimum;
			} else if (!min && max) {
				sash.state = SashState.AtMaximum;
			} else {
				sash.state = SashState.Enabled;
			}
		}
	}

	private getSashPosition(sash: Sash): number {
		let position = 0;

		for (let i = 0; i < this.sashItems.length; i++) {
			position += this.viewItems[i].size;

			if (this.sashItems[i].sash === sash) {
				return position;
			}
		}

		return 0;
	}

	private findFirstSnapIndex(indexes: number[]): number | undefined {
		// visible views first
		for (const index of indexes) {
			const viewItem = this.viewItems[index];

			if (!viewItem.visible) {
				continue;
			}

			if (viewItem.snap) {
				return index;
			}
		}

		// then, hidden views
		for (const index of indexes) {
			const viewItem = this.viewItems[index];

			if (viewItem.visible && viewItem.maximumSize - viewItem.minimumSize > 0) {
				return undefined;
			}

			if (!viewItem.visible && viewItem.snap) {
				return index;
			}
		}

		return undefined;
	}

	private areViewsDistributed() {
		let min = undefined, max = undefined;

		for (const view of this.viewItems) {
			min = min === undefined ? view.size : Math.min(min, view.size);
			max = max === undefined ? view.size : Math.max(max, view.size);

			if (max - min > 2) {
				return false;
			}
		}

		return true;
	}

	override dispose(): void {
		this.sashDragState?.disposable.dispose();

		dispose(this.viewItems);
		this.viewItems = [];

		this.sashItems.forEach(i => i.disposable.dispose());
		this.sashItems = [];

		super.dispose();
	}
}
