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

// We explicitly reference the jest typings since the jest.d.ts file shipped
// with jest 26 masks the @types/jest typings

/// <reference types="jest" />

import { PathExt } from '@jupyterlab/coreutils';
import { PartialJSONObject, ReadonlyJSONObject, UUID } from '@lumino/coreutils';
import { AttachedProperty } from '@lumino/properties';
import { ISignal, Signal } from '@lumino/signaling';
import { BaseManager } from './basemanager';
import { Contents, ContentsManager } from './contents';
import { Kernel, KernelMessage } from './kernel';
import { KernelSpec } from './kernelspec';
import { ServiceManager } from './manager';
import { ServerConnection } from './serverconnection';
import { Session } from './session';
import { User, UserManager } from './user';

// The default kernel name
export const DEFAULT_NAME = 'python3';

export const KERNELSPECS: { [key: string]: KernelSpec.ISpecModel } = {
  [DEFAULT_NAME]: {
    argv: [
      '/Users/someuser/miniconda3/envs/jupyterlab/bin/python',
      '-m',
      'ipykernel_launcher',
      '-f',
      '{connection_file}'
    ],
    display_name: 'Python 3',
    language: 'python',
    metadata: {},
    name: DEFAULT_NAME,
    resources: {}
  },
  irkernel: {
    argv: [
      '/Users/someuser/miniconda3/envs/jupyterlab/bin/python',
      '-m',
      'ipykernel_launcher',
      '-f',
      '{connection_file}'
    ],
    display_name: 'R',
    language: 'r',
    metadata: {},
    name: 'irkernel',
    resources: {}
  }
};

export const KERNEL_MODELS: Kernel.IModel[] = [
  {
    name: DEFAULT_NAME,
    id: UUID.uuid4()
  },
  {
    name: 'r',
    id: UUID.uuid4()
  },
  {
    name: DEFAULT_NAME,
    id: UUID.uuid4()
  }
];

// Notebook Paths for certain kernel name
export const NOTEBOOK_PATHS: { [kernelName: string]: string[] } = {
  python3: ['Untitled.ipynb', 'Untitled1.ipynb', 'Untitled2.ipynb'],
  r: ['Visualization.ipynb', 'Analysis.ipynb', 'Conclusion.ipynb']
};

/**
 * Clone a kernel connection.
 */
export function cloneKernel(
  kernel: Kernel.IKernelConnection
): Kernel.IKernelConnection {
  return (kernel as any).clone();
}

/**
 * A mock kernel object.
 *
 * @param model The model of the kernel
 */
export const KernelMock = jest.fn<
  Kernel.IKernelConnection,
  [Private.RecursivePartial<Kernel.IKernelConnection.IOptions>]
>(options => {
  const model = { id: 'foo', name: DEFAULT_NAME, ...options.model };
  options = {
    clientId: UUID.uuid4(),
    username: UUID.uuid4(),
    ...options,
    model
  };
  let executionCount = 0;
  const spec = Private.kernelSpecForKernelName(model.name)!;
  const thisObject: Kernel.IKernelConnection = {
    ...jest.requireActual('@jupyterlab/services'),
    ...options,
    ...model,
    model,
    serverSettings: ServerConnection.makeSettings(
      options.serverSettings as Partial<ServerConnection.ISettings>
    ),
    status: 'idle',
    spec: Promise.resolve(spec),
    dispose: jest.fn(),
    clone: jest.fn(() => {
      const newKernel = Private.cloneKernel(options);
      newKernel.iopubMessage.connect((_, args) => {
        iopubMessageSignal.emit(args);
      });
      newKernel.statusChanged.connect((_, args) => {
        (thisObject as any).status = args;
        statusChangedSignal.emit(args);
      });
      return newKernel;
    }),
    info: Promise.resolve(Private.getInfo(model!.name!)),
    shutdown: jest.fn(() => Promise.resolve(void 0)),
    requestHistory: jest.fn(() => {
      const historyReply = KernelMessage.createMessage({
        channel: 'shell',
        msgType: 'history_reply',
        session: options.clientId!,
        username: options.username!,
        content: {
          history: [],
          status: 'ok'
        }
      });
      return Promise.resolve(historyReply);
    }),
    restart: jest.fn(() => Promise.resolve(void 0)),
    requestExecute: jest.fn(options => {
      const msgId = UUID.uuid4();
      executionCount++;
      Private.lastMessageProperty.set(thisObject, msgId);
      const msg = KernelMessage.createMessage({
        channel: 'iopub',
        msgType: 'execute_input',
        session: thisObject.clientId,
        username: thisObject.username,
        msgId,
        content: {
          code: options.code,
          execution_count: executionCount
        }
      });
      iopubMessageSignal.emit(msg);
      const reply = KernelMessage.createMessage<KernelMessage.IExecuteReplyMsg>(
        {
          channel: 'shell',
          msgType: 'execute_reply',
          session: thisObject.clientId,
          username: thisObject.username,
          msgId,
          content: {
            user_expressions: {},
            execution_count: executionCount,
            status: 'ok'
          }
        }
      );
      return new MockShellFuture(reply) as Kernel.IShellFuture<
        KernelMessage.IExecuteRequestMsg,
        KernelMessage.IExecuteReplyMsg
      >;
    })
  };
  // Add signals.
  const iopubMessageSignal = new Signal<
    Kernel.IKernelConnection,
    KernelMessage.IIOPubMessage
  >(thisObject);
  const statusChangedSignal = new Signal<
    Kernel.IKernelConnection,
    Kernel.Status
  >(thisObject);
  const pendingInputSignal = new Signal<Kernel.IKernelConnection, boolean>(
    thisObject
  );
  (thisObject as any).statusChanged = statusChangedSignal;
  (thisObject as any).iopubMessage = iopubMessageSignal;
  (thisObject as any).pendingInput = pendingInputSignal;
  (thisObject as any).hasPendingInput = false;
  return thisObject;
});

/**
 * A mock session connection.
 *
 * @param options Addition session options to use
 * @param model A session model to use
 */
export const SessionConnectionMock = jest.fn<
  Session.ISessionConnection,
  [
    Private.RecursivePartial<Session.ISessionConnection.IOptions>,
    Kernel.IKernelConnection | null
  ]
>((options, kernel) => {
  const name = kernel?.name || options.model?.kernel?.name || DEFAULT_NAME;
  kernel = kernel || new KernelMock({ model: { name } });
  const model = {
    id: UUID.uuid4(),
    path: 'foo',
    type: 'notebook',
    name: 'foo',
    ...options.model,
    kernel: kernel!.model
  };
  const thisObject: Session.ISessionConnection = {
    ...jest.requireActual('@jupyterlab/services'),
    ...options,
    model,
    ...model,
    kernel,
    serverSettings: ServerConnection.makeSettings(
      options.serverSettings as Partial<ServerConnection.ISettings>
    ),
    dispose: jest.fn(),
    changeKernel: jest.fn(partialModel => {
      return changeKernel(kernel!, partialModel!);
    }),
    shutdown: jest.fn(() => Promise.resolve(void 0)),
    setPath: jest.fn(path => {
      (thisObject as any).path = path;
      propertyChangedSignal.emit('path');
      return Promise.resolve();
    }),
    setName: jest.fn(name => {
      (thisObject as any).name = name;
      propertyChangedSignal.emit('name');
      return Promise.resolve();
    }),
    setType: jest.fn(type => {
      (thisObject as any).type = type;
      propertyChangedSignal.emit('type');
      return Promise.resolve();
    })
  };
  const disposedSignal = new Signal<Session.ISessionConnection, undefined>(
    thisObject
  );
  const propertyChangedSignal = new Signal<
    Session.ISessionConnection,
    'path' | 'name' | 'type'
  >(thisObject);
  const statusChangedSignal = new Signal<
    Session.ISessionConnection,
    Kernel.Status
  >(thisObject);
  const connectionStatusChangedSignal = new Signal<
    Session.ISessionConnection,
    Kernel.ConnectionStatus
  >(thisObject);
  const kernelChangedSignal = new Signal<
    Session.ISessionConnection,
    Session.ISessionConnection.IKernelChangedArgs
  >(thisObject);
  const iopubMessageSignal = new Signal<
    Session.ISessionConnection,
    KernelMessage.IIOPubMessage
  >(thisObject);

  const unhandledMessageSignal = new Signal<
    Session.ISessionConnection,
    KernelMessage.IMessage
  >(thisObject);

  const pendingInputSignal = new Signal<Session.ISessionConnection, boolean>(
    thisObject
  );

  kernel!.iopubMessage.connect((_, args) => {
    iopubMessageSignal.emit(args);
  }, thisObject);

  kernel!.statusChanged.connect((_, args) => {
    statusChangedSignal.emit(args);
  }, thisObject);

  kernel!.pendingInput.connect((_, args) => {
    pendingInputSignal.emit(args);
  }, thisObject);

  (thisObject as any).disposed = disposedSignal;
  (thisObject as any).connectionStatusChanged = connectionStatusChangedSignal;
  (thisObject as any).propertyChanged = propertyChangedSignal;
  (thisObject as any).statusChanged = statusChangedSignal;
  (thisObject as any).kernelChanged = kernelChangedSignal;
  (thisObject as any).iopubMessage = iopubMessageSignal;
  (thisObject as any).unhandledMessage = unhandledMessageSignal;
  (thisObject as any).pendingInput = pendingInputSignal;
  return thisObject;
});

/**
 * A mock contents manager.
 */
export const ContentsManagerMock = jest.fn<Contents.IManager, []>(() => {
  const files = new Map<string, Contents.IModel>();
  const dummy = new ContentsManager();
  const checkpoints = new Map<string, Contents.ICheckpointModel>();
  const checkPointContent = new Map<string, string>();

  const baseModel = Private.createFile({ type: 'directory' });
  files.set('', { ...baseModel, path: '', name: '' });

  const thisObject: Contents.IManager = {
    ...jest.requireActual('@jupyterlab/services'),
    newUntitled: jest.fn(options => {
      const model = Private.createFile(options || {});
      files.set(model.path, model);
      fileChangedSignal.emit({
        type: 'new',
        oldValue: null,
        newValue: model
      });
      return Promise.resolve(model);
    }),
    createCheckpoint: jest.fn(path => {
      const lastModified = new Date().toISOString();
      const data = { id: UUID.uuid4(), last_modified: lastModified };
      checkpoints.set(path, data);
      checkPointContent.set(path, files.get(path)?.content);
      return Promise.resolve(data);
    }),
    listCheckpoints: jest.fn(path => {
      const p = checkpoints.get(path);
      if (p !== undefined) {
        return Promise.resolve([p]);
      }
      return Promise.resolve([]);
    }),
    deleteCheckpoint: jest.fn(path => {
      if (!checkpoints.has(path)) {
        return Private.makeResponseError(404);
      }
      checkpoints.delete(path);
      return Promise.resolve();
    }),
    restoreCheckpoint: jest.fn(path => {
      if (!checkpoints.has(path)) {
        return Private.makeResponseError(404);
      }
      (files.get(path) as any).content = checkPointContent.get(path);
      return Promise.resolve();
    }),
    getSharedModelFactory: jest.fn(() => {
      return null;
    }),
    normalize: jest.fn(path => {
      return dummy.normalize(path);
    }),
    localPath: jest.fn(path => {
      return dummy.localPath(path);
    }),
    resolvePath: jest.fn((root, path) => {
      return dummy.resolvePath(root, path);
    }),
    get: jest.fn((path, options) => {
      path = Private.fixSlash(path);
      if (!files.has(path)) {
        return Private.makeResponseError(404);
      }
      const model = files.get(path)!;
      const overrides: { hash?: string; last_modified?: string } = {};
      if (path == 'random-hash.txt') {
        overrides.hash = Math.random().toString();
      } else if (path == 'newer-timestamp-no-hash.txt') {
        overrides.hash = undefined;
        const tomorrow = new Date();
        tomorrow.setDate(new Date().getDate() + 1);
        overrides.last_modified = tomorrow.toISOString();
      }
      if (model.type === 'directory') {
        if (options?.content !== false) {
          const content: Contents.IModel[] = [];
          files.forEach(fileModel => {
            if (
              // If file path is under this directory, add it to contents array.
              PathExt.dirname(fileModel.path) == model.path &&
              // But the directory should exclude itself from the contents array.
              fileModel !== model
            ) {
              content.push(fileModel);
            }
          });
          return Promise.resolve({ ...model, content });
        }
        return Promise.resolve(model);
      }
      if (options?.content != false) {
        return Promise.resolve(model);
      }
      return Promise.resolve({ ...model, content: '', ...overrides });
    }),
    driveName: jest.fn(path => {
      return dummy.driveName(path);
    }),
    rename: jest.fn((oldPath, newPath) => {
      oldPath = Private.fixSlash(oldPath);
      newPath = Private.fixSlash(newPath);
      if (!files.has(oldPath)) {
        return Private.makeResponseError(404);
      }
      const oldValue = files.get(oldPath)!;
      files.delete(oldPath);
      const name = PathExt.basename(newPath);
      const newValue = { ...oldValue, name, path: newPath };
      files.set(newPath, newValue);
      fileChangedSignal.emit({
        type: 'rename',
        oldValue,
        newValue
      });
      return Promise.resolve(newValue);
    }),
    delete: jest.fn(path => {
      path = Private.fixSlash(path);
      if (!files.has(path)) {
        return Private.makeResponseError(404);
      }
      const oldValue = files.get(path)!;
      files.delete(path);
      fileChangedSignal.emit({
        type: 'delete',
        oldValue,
        newValue: null
      });
      return Promise.resolve(void 0);
    }),
    save: jest.fn((path, options) => {
      if (path == 'readonly.txt') {
        return Private.makeResponseError(403);
      }
      path = Private.fixSlash(path);
      const timeStamp = new Date().toISOString();
      if (files.has(path)) {
        const updates =
          path == 'frozen-time-and-hash.txt'
            ? {}
            : {
                last_modified: timeStamp,
                hash: timeStamp
              };
        files.set(path, {
          ...files.get(path)!,
          ...options,
          ...updates
        });
      } else {
        files.set(path, {
          path,
          name: PathExt.basename(path),
          content: '',
          writable: true,
          created: timeStamp,
          type: 'file',
          format: 'text',
          mimetype: 'plain/text',
          ...options,
          last_modified: timeStamp,
          hash: timeStamp,
          hash_algorithm: 'static'
        });
      }
      fileChangedSignal.emit({
        type: 'save',
        oldValue: null,
        newValue: files.get(path)!
      });
      return Promise.resolve(files.get(path)!);
    }),
    getDownloadUrl: jest.fn(path => {
      return dummy.getDownloadUrl(path);
    }),
    addDrive: jest.fn(drive => {
      dummy.addDrive(drive);
    }),
    dispose: jest.fn()
  };

  const fileChangedSignal = new Signal<
    Contents.IManager,
    Contents.IChangedArgs
  >(thisObject);
  (thisObject as any).fileChanged = fileChangedSignal;
  return thisObject;
});

/**
 * A mock sessions manager.
 */
export const SessionManagerMock = jest.fn<Session.IManager, []>(() => {
  let sessions: Session.IModel[] = [];
  const thisObject: Session.IManager = {
    ...jest.requireActual('@jupyterlab/services'),
    ready: Promise.resolve(void 0),
    isReady: true,
    startNew: jest.fn(options => {
      const session = new SessionConnectionMock({ model: options }, null);
      sessions.push(session.model);
      runningChangedSignal.emit(sessions);
      return Promise.resolve(session);
    }),
    connectTo: jest.fn(options => {
      return new SessionConnectionMock(options, null);
    }),
    stopIfNeeded: jest.fn(path => {
      const length = sessions.length;
      sessions = sessions.filter(model => model.path !== path);
      if (sessions.length !== length) {
        runningChangedSignal.emit(sessions);
      }
      return Promise.resolve(void 0);
    }),
    refreshRunning: jest.fn(() => Promise.resolve(void 0)),
    running: jest.fn(() => sessions[Symbol.iterator]())
  };

  const runningChangedSignal = new Signal<Session.IManager, Session.IModel[]>(
    thisObject
  );
  (thisObject as any).runningChanged = runningChangedSignal;
  return thisObject;
});

/**
 * A mock kernel specs manager
 */
export const KernelSpecManagerMock = jest.fn<KernelSpec.IManager, []>(() => {
  const thisObject: KernelSpec.IManager = {
    ...jest.requireActual('@jupyterlab/services'),
    specs: { default: DEFAULT_NAME, kernelspecs: KERNELSPECS },
    isReady: true,
    ready: Promise.resolve(void 0),
    refreshSpecs: jest.fn(() => Promise.resolve(void 0))
  };
  return thisObject;
});

/**
 * A mock service manager.
 */
export const ServiceManagerMock = jest.fn<ServiceManager.IManager, []>(() => {
  const thisObject: ServiceManager.IManager = {
    ...jest.requireActual('@jupyterlab/services'),
    ready: Promise.resolve(void 0),
    isReady: true,
    contents: new ContentsManagerMock(),
    sessions: new SessionManagerMock(),
    kernelspecs: new KernelSpecManagerMock(),
    dispose: jest.fn()
  };
  return thisObject;
});

/**
 * A mock kernel shell future.
 */
export const MockShellFuture = jest.fn<
  Kernel.IShellFuture,
  [KernelMessage.IShellMessage]
>((result: KernelMessage.IShellMessage) => {
  const thisObject: Kernel.IShellFuture = {
    ...jest.requireActual('@jupyterlab/services'),
    dispose: jest.fn(),
    done: Promise.resolve(result)
  };
  return thisObject;
});

export function changeKernel(
  kernel: Kernel.IKernelConnection,
  partialModel: Partial<Kernel.IModel>
): Promise<Kernel.IKernelConnection> {
  if (partialModel.id) {
    const kernelIdx = KERNEL_MODELS.findIndex(model => {
      return model.id === partialModel.id;
    });
    if (kernelIdx !== -1) {
      (kernel.model as any) = Private.RUNNING_KERNELS[kernelIdx].model;
      (kernel.id as any) = partialModel.id;
      return Promise.resolve(Private.RUNNING_KERNELS[kernelIdx]);
    } else {
      throw new Error(
        `Unable to change kernel to one with id: ${partialModel.id}`
      );
    }
  } else if (partialModel.name) {
    const kernelIdx = KERNEL_MODELS.findIndex(model => {
      return model.name === partialModel.name;
    });
    if (kernelIdx !== -1) {
      (kernel.model as any) = Private.RUNNING_KERNELS[kernelIdx].model;
      (kernel.id as any) = partialModel.id;
      return Promise.resolve(Private.RUNNING_KERNELS[kernelIdx]);
    } else {
      throw new Error(
        `Unable to change kernel to one with name: ${partialModel.name}`
      );
    }
  } else {
    throw new Error(`Unable to change kernel`);
  }
}

/**
 * A namespace for module private data.
 */
namespace Private {
  export type RecursivePartial<T> = {
    [P in keyof T]?: RecursivePartial<T[P]>;
  };

  export function createFile(
    options?: Contents.ICreateOptions
  ): Contents.IModel {
    options = options || {};
    let name = UUID.uuid4();
    switch (options.type) {
      case 'directory':
        name = `Untitled Folder_${name}`;
        break;
      case 'notebook':
        name = `Untitled_${name}.ipynb`;
        break;
      default:
        name = `untitled_${name}${options.ext || '.txt'}`;
    }

    const path = PathExt.join(options.path || '', name);
    let content = '';
    if (options.type === 'notebook') {
      content = JSON.stringify({});
    }
    const timeStamp = new Date().toISOString();
    return {
      path,
      content,
      name,
      last_modified: timeStamp,
      writable: true,
      created: timeStamp,
      type: options.type || 'file',
      format: 'text',
      mimetype: 'plain/text'
    };
  }

  export function fixSlash(path: string): string {
    if (path.endsWith('/')) {
      path = path.slice(0, path.length - 1);
    }
    return path;
  }

  export function makeResponseError<T>(status: number): Promise<T> {
    const resp = new Response(void 0, { status });
    return Promise.reject(new ServerConnection.ResponseError(resp));
  }

  export function cloneKernel(
    options: RecursivePartial<Kernel.IKernelConnection.IOptions>
  ): Kernel.IKernelConnection {
    return new KernelMock({ ...options, clientId: UUID.uuid4() });
  }

  // Get the kernel spec for kernel name
  export function kernelSpecForKernelName(name: string): KernelSpec.ISpecModel {
    return KERNELSPECS[name];
  }

  // Get the kernel info for kernel name
  export function getInfo(name: string): KernelMessage.IInfoReply {
    return {
      protocol_version: '1',
      implementation: 'foo',
      implementation_version: '1',
      language_info: {
        version: '1',
        name
      },
      banner: 'hello, world!',
      help_links: [],
      status: 'ok'
    };
  }

  // This list of running kernels simply mirrors the KERNEL_MODELS and KERNELSPECS lists
  export const RUNNING_KERNELS: Kernel.IKernelConnection[] = KERNEL_MODELS.map(
    (model, _) => {
      return new KernelMock({ model });
    }
  );

  export const lastMessageProperty = new AttachedProperty<
    Kernel.IKernelConnection,
    string
  >({
    name: 'lastMessageId',
    create: () => ''
  });
}

/**
 * The user API service manager.
 */
export class FakeUserManager extends BaseManager implements User.IManager {
  private _isReady = false;
  private _ready: Promise<void>;

  private _identity: User.IIdentity;
  private _permissions: ReadonlyJSONObject;

  private _userChanged = new Signal<this, User.IUser>(this);
  private _connectionFailure = new Signal<this, Error>(this);

  /**
   * Create a new user manager.
   */
  constructor(
    options: UserManager.IOptions = {},
    identity: User.IIdentity,
    permissions: ReadonlyJSONObject
  ) {
    super(options);

    // Initialize internal data.
    this._ready = new Promise<void>(resolve => {
      // Schedule updating the user to the next macro task queue.
      setTimeout(() => {
        this._identity = identity;
        this._permissions = permissions;

        this._userChanged.emit({
          identity: this._identity,
          permissions: this._permissions as PartialJSONObject
        });

        resolve();
      }, 0);
    })
      .then(() => {
        if (this.isDisposed) {
          return;
        }
        this._isReady = true;
      })
      .catch(_ => undefined);
  }

  /**
   * The server settings for the manager.
   */
  readonly serverSettings: ServerConnection.ISettings;

  /**
   * Test whether the manager is ready.
   */
  get isReady(): boolean {
    return this._isReady;
  }

  /**
   * A promise that fulfills when the manager is ready.
   */
  get ready(): Promise<void> {
    return this._ready;
  }

  /**
   * Get the most recently fetched identity.
   */
  get identity(): User.IIdentity | null {
    return this._identity;
  }

  /**
   * Get the most recently fetched permissions.
   */
  get permissions(): ReadonlyJSONObject | null {
    return this._permissions;
  }

  /**
   * A signal emitted when the user changes.
   */
  get userChanged(): ISignal<this, User.IUser> {
    return this._userChanged;
  }

  /**
   * A signal emitted when there is a connection failure.
   */
  get connectionFailure(): ISignal<this, Error> {
    return this._connectionFailure;
  }

  /**
   * Dispose of the resources used by the manager.
   */
  dispose(): void {
    super.dispose();
  }

  /**
   * Force a refresh of the specs from the server.
   *
   * @returns A promise that resolves when the specs are fetched.
   *
   * #### Notes
   * This is intended to be called only in response to a user action,
   * since the manager maintains its internal state.
   */
  async refreshUser(): Promise<void> {
    return Promise.resolve();
  }
}
