import { PageConfig, URLExt } from '@jupyterlab/coreutils';
import type { Contents, Terminal } from '@jupyterlab/services';
import { ServerConnection } from '@jupyterlab/services';
import type {
  IExternalCommand,
  IOutputCallback,
  IShell,
  IShellManager,
  IStdinReply,
  IStdinRequest
} from '@jupyterlite/cockle';
import { ShellManager } from '@jupyterlite/cockle';
import type { JSONPrimitive } from '@lumino/coreutils';
import type { ISignal } from '@lumino/signaling';
import { Signal } from '@lumino/signaling';
import type { Client as WebSocketClient } from 'mock-socket';
import { Server as WebSocketServer } from 'mock-socket';
import type { ITerminalShell } from './shell';
import { TerminalShell } from './shell';
import type { ILiteTerminalAPIClient } from './tokens';

/**
 * Default time (in milliseconds) to wait for a new shell to become ready.
 * A shell that fails to start is disposed without its `ready` promise
 * rejecting, so race the wait against this timeout to surface the failure.
 */
const DEFAULT_READY_TIMEOUT_MS = 30000;

export class LiteTerminalAPIClient implements ILiteTerminalAPIClient {
  constructor(options: { serverSettings?: ServerConnection.ISettings } = {}) {
    this.serverSettings = options.serverSettings ?? ServerConnection.makeSettings();
  }

  /**
   * Set identifier for communicating with service worker.
   */
  set browsingContextId(browsingContextId: string) {
    this._browsingContextId = browsingContextId;
  }

  /**
   * Set contents manager used for SharedArrayBuffer DriveFS.
   */
  set contentsManager(contentsManager: Contents.IManager) {
    this._contentsManager = contentsManager;
  }

  /**
   * Function that handles stdin requests received from service worker.
   */
  async handleStdin(request: IStdinRequest): Promise<IStdinReply> {
    return await Private.shellManager.handleStdin(request);
  }

  get isAvailable(): boolean {
    const available = String(PageConfig.getOption('terminalsAvailable'));
    return available.toLowerCase() === 'true';
  }

  readonly serverSettings: ServerConnection.ISettings;

  async startNew(options?: Terminal.ITerminal.IOptions): Promise<Terminal.IModel> {
    // Create shell.
    const name = options?.name ?? this._nextAvailableName();
    const { baseUrl, wsUrl } = this.serverSettings;
    const shell = await this.createShell({
      mountpoint: '/drive',
      cwd: options?.cwd,
      baseUrl,
      wasmBaseUrl: URLExt.join(baseUrl, 'extensions/@jupyterlite/terminal/static/wasm/'),
      browsingContextId: this._browsingContextId,
      contentsManager: this._contentsManager,
      aliases: this._aliases,
      environment: this._environment,
      externalCommands: this._externalCommands,
      shellId: name,
      shellManager: Private.shellManager,
      outputCallback: text => {
        const msg = JSON.stringify(['stdout', text]);
        shell.socket?.send(msg);
      }
    });
    Private.shells.set(name, shell);

    // Hook to connect socket to shell.
    const hook = async (shell: ITerminalShell, socket: WebSocketClient): Promise<void> => {
      shell.socket = socket;

      socket.on('message', async (message: any) => {
        // Message from xtermjs to pass to shell.
        const data = JSON.parse(message) as JSONPrimitive[];
        const message_type = data[0];
        const content = data.slice(1);
        await shell.ready;
        if (message_type === 'stdin') {
          await shell.input(content[0] as string);
        } else if (message_type === 'set_size') {
          const rows = content[0] as number;
          const columns = content[1] as number;
          await shell.setSize({ rows, columns });
        }
      });

      // Return handshake.
      const res = JSON.stringify(['setup']);
      console.log('Terminal returning handshake via socket');
      socket.send(res);

      shell.start();
    };

    const url = URLExt.join(wsUrl, 'terminals', 'websocket', name);
    const wsServer = new WebSocketServer(url);
    wsServer.on('connection', (socket: WebSocketClient): void => {
      hook(shell, socket);
    });

    shell.disposed.connect(() => {
      this.shutdown(name);
      wsServer.close();
      this._terminalDisposed.emit(shell.shellId);
    });

    return { name };
  }

  async listRunning(): Promise<Terminal.IModel[]> {
    return this._models;
  }

  registerAlias(key: string, value: string): void {
    if (this._aliases === undefined) {
      this._aliases = {};
    }
    this._aliases[key] = value;
  }

  registerEnvironmentVariable(key: string, value: string | undefined): void {
    if (this._environment === undefined) {
      this._environment = {};
    }
    this._environment[key] = value;
  }

  registerExternalCommand(options: IExternalCommand.IOptions): void {
    this._externalCommands.push(options);
  }

  async createHeadlessShell(options: {
    shellId: string;
    cwd?: string;
    environment?: { [key: string]: string | undefined };
    outputCallback: IOutputCallback;
    readyTimeoutMs?: number;
  }): Promise<IShell> {
    const { baseUrl } = this.serverSettings;
    const environment =
      options.environment !== undefined
        ? { ...this._environment, ...options.environment }
        : this._environment;
    // `color: true` keeps TERM/TERMINFO populated for commands that need them.
    const shell = await this.createShell({
      shellId: options.shellId,
      mountpoint: '/drive',
      cwd: options.cwd,
      baseUrl,
      wasmBaseUrl: URLExt.join(baseUrl, 'extensions/@jupyterlite/terminal/static/wasm/'),
      browsingContextId: this._browsingContextId,
      contentsManager: this._contentsManager,
      shellManager: Private.shellManager,
      aliases: this._aliases,
      environment,
      externalCommands: this._externalCommands,
      color: true,
      outputCallback: options.outputCallback
    });

    const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
    let readyTimer: ReturnType<typeof setTimeout> | undefined;
    try {
      await Promise.race([
        shell.ready,
        new Promise<never>((_, reject) => {
          readyTimer = setTimeout(
            () =>
              reject(
                new Error(
                  `Timed out after ${readyTimeoutMs}ms waiting for cockle shell '${options.shellId}' to become ready`
                )
              ),
            readyTimeoutMs
          );
        })
      ]);
      await shell.start();
    } catch (err) {
      shell.dispose();
      throw err;
    } finally {
      if (readyTimer !== undefined) {
        clearTimeout(readyTimer);
      }
    }

    return shell;
  }

  async shutdown(name: string): Promise<void> {
    const shell = Private.shells.get(name);
    if (shell !== undefined) {
      shell.socket?.send(JSON.stringify(['disconnect']));
      shell.socket?.close();
      Private.shells.delete(name);
      shell.dispose();
    }
  }

  get terminalDisposed(): ISignal<this, string> {
    return this._terminalDisposed;
  }

  themeChange(isDarkMode?: boolean): void {
    for (const shell of Private.shells.values()) {
      shell.themeChange(isDarkMode);
    }
  }

  protected async createShell(options: ITerminalShell.IOptions): Promise<ITerminalShell> {
    return new TerminalShell(options);
  }

  private get _models(): Terminal.IModel[] {
    return Array.from(Private.shells.keys(), name => {
      return { name };
    });
  }

  private _nextAvailableName(): string {
    for (let i = 1; ; ++i) {
      const name = `${i}`;
      if (!Private.shells.has(name)) {
        return name;
      }
    }
  }

  private _aliases?: { [key: string]: string };
  private _environment?: { [key: string]: string | undefined };
  private _browsingContextId?: string;
  private _contentsManager?: Contents.IManager;
  private _externalCommands: IExternalCommand.IOptions[] = [];
  private _terminalDisposed = new Signal<this, string>(this);
}

/**
 * A namespace for private data.
 */
namespace Private {
  export const shellManager: IShellManager = new ShellManager();
  export const shells = new Map<string, ITerminalShell>();
}
