// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import { showErrorMessage } from '@jupyterlab/apputils';
import { PathExt } from '@jupyterlab/coreutils';
import { IDocumentManager } from '@jupyterlab/docmanager';
import { Contents, ServerConnection } from '@jupyterlab/services';
import { IStateDB } from '@jupyterlab/statedb';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
import {
  FilenameSearcher,
  IScore,
  SidePanel,
  Toolbar
} from '@jupyterlab/ui-components';
import { ISignal, Signal } from '@lumino/signaling';
import { Panel } from '@lumino/widgets';
import { createRef } from 'react';
import { BreadCrumbs } from './crumbs';
import { DirListing } from './listing';
import { FilterFileBrowserModel } from './model';

/**
 * The class name added to file browsers.
 */
const FILE_BROWSER_CLASS = 'jp-FileBrowser';

/**
 * The class name added to file browser panel (gather filter, breadcrumbs and listing).
 */
const FILE_BROWSER_PANEL_CLASS = 'jp-FileBrowser-Panel';

/**
 * The class name added to the filebrowser crumbs node.
 */
const CRUMBS_CLASS = 'jp-FileBrowser-crumbs';

/**
 * The class name added to the filebrowser toolbar node.
 */
const TOOLBAR_CLASS = 'jp-FileBrowser-toolbar';

/**
 * The class name added to the filebrowser filter toolbar node.
 */
const FILTER_TOOLBAR_CLASS = 'jp-FileBrowser-filterToolbar';

/**
 * The class name added to the filebrowser listing node.
 */
const LISTING_CLASS = 'jp-FileBrowser-listing';

/**
 * The class name added to the filebrowser filterbox node.
 */
const FILTERBOX_CLASS = 'jp-FileBrowser-filterBox';

/**
 * A widget which hosts a file browser.
 *
 * The widget uses the Jupyter Contents API to retrieve contents,
 * and presents itself as a flat list of files and directories with
 * breadcrumbs.
 */
export class FileBrowser extends SidePanel {
  /**
   * Construct a new file browser.
   *
   * @param options - The file browser options.
   */
  constructor(options: FileBrowser.IOptions) {
    super({ content: new Panel(), translator: options.translator });
    this.addClass(FILE_BROWSER_CLASS);
    this.toolbar.addClass(TOOLBAR_CLASS);
    this.id = options.id;
    const translator = (this.translator = options.translator ?? nullTranslator);

    const model = (this.model = options.model);
    const renderer = options.renderer;

    model.connectionFailure.connect(this._onConnectionFailure, this);
    this._manager = model.manager;

    this.toolbar.node.setAttribute(
      'aria-label',
      this._trans.__('file browser')
    );

    // File browser widgets container
    this.mainPanel = new Panel();
    this.mainPanel.addClass(FILE_BROWSER_PANEL_CLASS);
    this.mainPanel.title.label = this._trans.__('File Browser');

    this.crumbs = new BreadCrumbs({ model, translator });
    this.crumbs.addClass(CRUMBS_CLASS);

    // The filter toolbar appears immediately below the breadcrumbs and above the directory listing.
    const searcher = FilenameSearcher({
      updateFilter: (
        filterFn: (item: string) => Partial<IScore> | null,
        query?: string
      ) => {
        this.model.setFilter(value => {
          return filterFn(value.name.toLowerCase());
        });
      },
      useFuzzyFilter: this.model.useFuzzyFilter,
      placeholder: this._trans.__('Filter files by name'),
      forceRefresh: false,
      showIcon: false,
      inputRef: this._fileFilterRef,
      filterSettingsChanged: this.model.filterSettingsChanged
    });
    searcher.addClass(FILTERBOX_CLASS);

    this.filterToolbar = new Toolbar();
    this.filterToolbar.addClass(FILTER_TOOLBAR_CLASS);
    this.filterToolbar.node.setAttribute(
      'aria-label',
      this._trans.__('File browser toolbar')
    );
    this.filterToolbar.addItem('fileNameSearcher', searcher);
    this.filterToolbar.setHidden(!this.showFileFilter);

    this.listing = this.createDirListing({
      model,
      renderer,
      translator,
      state: options.state,
      handleOpenFile: options.handleOpenFile
    });
    this.listing.addClass(LISTING_CLASS);
    this.listing.selectionChanged.connect(() => {
      this._selectionChanged.emit();
    });

    this.mainPanel.addWidget(this.crumbs);
    this.mainPanel.addWidget(this.filterToolbar);
    this.mainPanel.addWidget(this.listing);

    this.addWidget(this.mainPanel);

    if (options.restore !== false) {
      void model.restore(this.id);
    }
    // restore listing regardless of the restore option
    void this.listing.restore(this.id);
  }

  /**
   * The model used by the file browser.
   */
  readonly model: FilterFileBrowserModel;

  /**
   * Whether to show active file in file browser
   */
  get navigateToCurrentDirectory(): boolean {
    return this._navigateToCurrentDirectory;
  }

  set navigateToCurrentDirectory(value: boolean) {
    this._navigateToCurrentDirectory = value;
  }

  /**
   * Whether to show the last modified column
   */
  get showLastModifiedColumn(): boolean {
    return this._showLastModifiedColumn;
  }

  set showLastModifiedColumn(value: boolean) {
    if (this.listing.setColumnVisibility) {
      this.listing.setColumnVisibility('last_modified', value);
      this._showLastModifiedColumn = value;
    } else {
      console.warn('Listing does not support toggling column visibility');
    }
  }

  /**
   * Number of directory items to show on the left side of the ellipsis in breadcrumbs.
   */
  get minimumBreadcrumbsLeftItems(): number {
    return this.crumbs.minimumLeftItems;
  }

  set minimumBreadcrumbsLeftItems(value: number) {
    this.crumbs.minimumLeftItems = value;
  }

  /**
   * Number of directory items to show on the right side of the ellipsis in breadcrumbs.
   */
  get minimumBreadcrumbsRightItems(): number {
    return this.crumbs.minimumRightItems;
  }

  set minimumBreadcrumbsRightItems(value: number) {
    this.crumbs.minimumRightItems = value;
  }

  /**
   * Whether to show the full path in the breadcrumbs
   */
  get showFullPath(): boolean {
    return this.crumbs.fullPath;
  }

  set showFullPath(value: boolean) {
    this.crumbs.fullPath = value;
  }

  /**
   * Whether to show the file size column
   */
  get showFileSizeColumn(): boolean {
    return this._showFileSizeColumn;
  }

  set showFileSizeColumn(value: boolean) {
    if (this.listing.setColumnVisibility) {
      this.listing.setColumnVisibility('file_size', value);
      this._showFileSizeColumn = value;
    } else {
      console.warn('Listing does not support toggling column visibility');
    }
  }

  /**
   * Whether to show hidden files
   */
  get showHiddenFiles(): boolean {
    return this._showHiddenFiles;
  }

  set showHiddenFiles(value: boolean) {
    this.model.showHiddenFiles(value);
    this._showHiddenFiles = value;
  }

  /**
   * Whether to show checkboxes next to files and folders
   */
  get showFileCheckboxes(): boolean {
    return this._showFileCheckboxes;
  }

  set showFileCheckboxes(value: boolean) {
    if (this.listing.setColumnVisibility) {
      this.listing.setColumnVisibility('is_selected', value);
      this._showFileCheckboxes = value;
    } else {
      console.warn('Listing does not support toggling column visibility');
    }
  }

  /**
   * Whether to show a text box to filter files by name.
   */
  get showFileFilter(): boolean {
    return this._showFileFilter;
  }

  set showFileFilter(value: boolean) {
    // If the old value was true and the new value is false, clear the filter
    const oldValue = this.showFileFilter;
    if (oldValue && !value) {
      // Clear the search box input
      if (this._fileFilterRef.current) {
        this._fileFilterRef.current.value = '';
      }

      // Set a filter that doesn't exclude anything.
      this.model.setFilter(value => {
        return {};
      });
      this.model.refresh().catch(console.warn);
    }
    this._showFileFilter = value;

    // Update widget visibility
    this.filterToolbar.setHidden(!this.showFileFilter);
    if (this.showFileFilter) {
      this._fileFilterRef.current?.focus();
    }
  }

  /**
   * Whether to sort notebooks above other files
   */
  get sortNotebooksFirst(): boolean {
    return this._sortNotebooksFirst;
  }

  set sortNotebooksFirst(value: boolean) {
    if (this.listing.setNotebooksFirstSorting) {
      this.listing.setNotebooksFirstSorting(value);
      this._sortNotebooksFirst = value;
    } else {
      console.warn('Listing does not support sorting notebooks first');
    }
  }

  /**
   * Whether to allow single click files and directories
   */
  get singleClickNavigation(): boolean {
    return this._allowSingleClick;
  }

  set singleClickNavigation(value: boolean) {
    if (this.listing.setAllowSingleClickNavigation) {
      this.listing.setAllowSingleClickNavigation(value);
      this._allowSingleClick = value;
    } else {
      console.warn('Listing does not support single click navigation');
    }
  }

  /**
   * Whether to allow upload of files.
   */
  get allowFileUploads(): boolean {
    return this._allowFileUploads;
  }

  set allowFileUploads(value: boolean) {
    this.model.allowFileUploads = value;
    if (this.listing.setAllowDragDropUpload) {
      this.listing.setAllowDragDropUpload(value);
      this._allowFileUploads = value;
    } else {
      console.warn('Listing does not support setting upload');
    }
  }

  /**
   * Create an iterator over the listing's selected items.
   *
   * @returns A new iterator over the listing's selected items.
   */
  selectedItems(): IterableIterator<Contents.IModel> {
    return this.listing.selectedItems();
  }

  /**
   * A signal emitted when the selection changes in the file browser.
   */
  get selectionChanged(): ISignal<this, void> {
    return this._selectionChanged;
  }

  /**
   * Select an item by name.
   *
   * @param name - The name of the item to select.
   */
  async selectItemByName(name: string): Promise<void> {
    await this.listing.selectItemByName(name);
  }

  clearSelectedItems(): void {
    this.listing.clearSelectedItems();
  }

  /**
   * Rename the first currently selected item.
   *
   * @returns A promise that resolves with the new name of the item.
   */
  rename(): Promise<string> {
    return this.listing.rename();
  }

  /**
   * Cut the selected items.
   */
  cut(): void {
    this.listing.cut();
  }

  /**
   * Copy the selected items.
   */
  copy(): void {
    this.listing.copy();
  }

  /**
   * Paste the items from the clipboard.
   *
   * @returns A promise that resolves when the operation is complete.
   */
  paste(): Promise<void> {
    return this.listing.paste();
  }

  private async _createNew(
    options: Contents.ICreateOptions
  ): Promise<Contents.IModel> {
    // normalize the path if the file is created from a custom drive
    if (options.path) {
      const localPath = this._manager.services.contents.localPath(options.path);
      options.path = this._toDrivePath(this.model.driveName, localPath);
    }
    try {
      const model = await this._manager.newUntitled(options);
      await this.listing.selectItemByName(model.name, true);
      await this.rename();
      return model;
    } catch (err) {
      void showErrorMessage(this._trans.__('Error'), err);
      throw err;
    }
  }

  /**
   * Create a new directory
   */
  async createNewDirectory(): Promise<Contents.IModel> {
    if (this._directoryPending) {
      return this._directoryPending;
    }
    this._directoryPending = this._createNew({
      path: this.model.path,
      type: 'directory'
    });
    try {
      return await this._directoryPending;
    } finally {
      this._directoryPending = null;
    }
  }

  /**
   * Create a new file
   */
  async createNewFile(
    options: FileBrowser.IFileOptions
  ): Promise<Contents.IModel> {
    if (this._filePending) {
      return this._filePending;
    }
    this._filePending = this._createNew({
      path: this.model.path,
      type: 'file',
      ext: options.ext
    });
    try {
      return await this._filePending;
    } finally {
      this._filePending = null;
    }
  }

  /**
   * Delete the currently selected item(s).
   *
   * @returns A promise that resolves when the operation is complete.
   */
  delete(): Promise<void> {
    return this.listing.delete();
  }

  /**
   * Duplicate the currently selected item(s).
   *
   * @returns A promise that resolves when the operation is complete.
   */
  duplicate(): Promise<void> {
    return this.listing.duplicate();
  }

  /**
   * Select all listing items.
   */
  selectAll(): Promise<void> {
    return this.listing.selectAll();
  }

  /**
   * Download the currently selected item(s).
   */
  download(): Promise<void> {
    return this.listing.download();
  }

  /**
   * cd ..
   *
   * Go up one level in the directory tree.
   */
  async goUp() {
    return this.listing.goUp();
  }

  /**
   * Shut down kernels on the applicable currently selected items.
   *
   * @returns A promise that resolves when the operation is complete.
   */
  shutdownKernels(): Promise<void> {
    return this.listing.shutdownKernels();
  }

  /**
   * Select next item.
   */
  selectNext(): void {
    this.listing.selectNext();
  }

  /**
   * Select previous item.
   */
  selectPrevious(): void {
    this.listing.selectPrevious();
  }

  /**
   * Find a model given a click.
   *
   * @param event - The mouse event.
   *
   * @returns The model for the selected file.
   */
  modelForClick(event: MouseEvent): Contents.IModel | undefined {
    return this.listing.modelForClick(event);
  }

  /**
   * Create the underlying DirListing instance.
   *
   * @param options - The DirListing constructor options.
   *
   * @returns The created DirListing instance.
   */
  protected createDirListing(options: DirListing.IOptions): DirListing {
    return new DirListing(options);
  }

  protected translator: ITranslator;

  /**
   * Handle a connection lost signal from the model.
   */
  private _onConnectionFailure(
    sender: FilterFileBrowserModel,
    args: Error
  ): void {
    if (
      args instanceof ServerConnection.ResponseError &&
      args.response.status === 404
    ) {
      const title = this._trans.__('Directory not found');
      args.message = this._trans.__(
        'Directory not found: "%1"',
        this.model.path
      );
      void showErrorMessage(title, args);
    }
  }

  /**
   * Given a drive name and a local path, return the full
   * drive path which includes the drive name and the local path.
   *
   * @param driveName the name of the drive
   * @param localPath the local path on the drive.
   *
   * @returns the full drive path
   */
  private _toDrivePath(driveName: string, localPath: string): string {
    if (driveName === '') {
      return localPath;
    } else {
      return `${driveName}:${PathExt.removeSlash(localPath)}`;
    }
  }

  protected filterToolbar: Toolbar;
  protected listing: DirListing;
  protected crumbs: BreadCrumbs;
  protected mainPanel: Panel;

  private _directoryPending: Promise<Contents.IModel> | null = null;
  private _filePending: Promise<Contents.IModel> | null = null;
  private _fileFilterRef = createRef<HTMLInputElement>();
  private _manager: IDocumentManager;
  private _navigateToCurrentDirectory: boolean;
  private _allowSingleClick: boolean = false;
  private _showFileCheckboxes: boolean = false;
  private _showFileFilter: boolean = false;
  private _showFileSizeColumn: boolean = false;
  private _showHiddenFiles: boolean = false;
  private _showLastModifiedColumn: boolean = true;
  private _sortNotebooksFirst: boolean = false;
  private _allowFileUploads: boolean = true;
  private _selectionChanged = new Signal<this, void>(this);
}

/**
 * The namespace for the `FileBrowser` class statics.
 */
export namespace FileBrowser {
  /**
   * An options object for initializing a file browser widget.
   */
  export interface IOptions {
    /**
     * The widget/DOM id of the file browser.
     */
    id: string;

    /**
     * A file browser model instance.
     */
    model: FilterFileBrowserModel;

    /**
     * An optional renderer for the directory listing area.
     *
     * The default is a shared instance of `DirListing.Renderer`.
     */
    renderer?: DirListing.IRenderer;

    /**
     * Whether a file browser automatically restores state when instantiated.
     * The default is `true`.
     *
     * #### Notes
     * The file browser model will need to be restored manually for the file
     * browser to be able to save its state.
     */
    restore?: boolean;

    /**
     * The application language translator.
     */
    translator?: ITranslator;

    /**
     * An optional state database. If provided, the widget will restore
     * the columns sizes
     */
    state?: IStateDB;

    /**
     * Callback overriding action performed when user asks to open a file.
     * The default is to open the file in the main area if it is not open already, or to reveal it otherwise.
     */
    handleOpenFile?: (path: string) => void;
  }

  /**
   * An options object for creating a file.
   */
  export interface IFileOptions {
    /**
     * The file extension.
     */
    ext: string;
  }
}
