/**
 * @file Manages Salesforce Metadata API
 * @author Shinichi Tomita <shinichi.tomita@gmail.com>
 */
import { EventEmitter } from 'events';
import { Readable } from 'stream';
import FormData from 'form-data';
import { registerModule } from '../jsforce';
import Connection from '../connection';
import SOAP from '../soap';
import { isObject } from '../util/function';
import { Schema, SoapSchemaDef, SoapSchema, HttpRequest } from '../types';
import {
  ApiSchemas,
  Metadata,
  ReadResult,
  SaveResult,
  UpsertResult,
  ListMetadataQuery,
  FileProperties,
  DescribeMetadataResult,
  RetrieveRequest,
  DeployOptions,
  RetrieveResult,
  DeployResult,
  AsyncResult,
  ApiSchemaTypes, CancelDeployResult,
} from './metadata/schema';
export * from './metadata/schema';

/**
 *
 */
type MetadataType_<
  K extends keyof ApiSchemaTypes = keyof ApiSchemaTypes
> = K extends keyof ApiSchemaTypes
  ? ApiSchemaTypes[K] extends Metadata
    ? K
    : never
  : never;

export type MetadataType = MetadataType_;

export type MetadataDefinition<
  T extends string,
  M extends Metadata = Metadata
> = Metadata extends M
  ? T extends keyof ApiSchemaTypes & MetadataType
    ? ApiSchemaTypes[T] extends Metadata
      ? ApiSchemaTypes[T]
      : Metadata
    : Metadata
  : M;

type DeepPartial<T> = T extends any[]
  ? Array<DeepPartial<T[number]>>
  : T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

export type InputMetadataDefinition<
  T extends string,
  M extends Metadata = Metadata
> = DeepPartial<MetadataDefinition<T, M>>;

/**
 *
 */
function deallocateTypeWithMetadata<M extends Metadata>(metadata: M): M {
  const { $, ...md } = metadata as any;
  return md;
}

function assignTypeWithMetadata(metadata: Metadata | Metadata[], type: string) {
  const convert = (md: Metadata) => ({ ['@xsi:type']: type, ...md });
  return Array.isArray(metadata) ? metadata.map(convert) : convert(metadata);
}

/**
 * Class for Salesforce Metadata API
 */
export class MetadataApi<S extends Schema> {
  _conn: Connection<S>;

  /**
   * Polling interval in milliseconds
   */
  pollInterval: number = 1000;

  /**
   * Polling timeout in milliseconds
   */
  pollTimeout: number = 10000;

  /**
   *
   */
  constructor(conn: Connection<S>) {
    this._conn = conn;
  }

  /**
   * Call Metadata API SOAP endpoint
   *
   * @private
   */
  async _invoke(
    method: string,
    message: object,
    schema?: SoapSchema | SoapSchemaDef,
  ) {
    const soapEndpoint = new SOAP(this._conn, {
      xmlns: 'http://soap.sforce.com/2006/04/metadata',
      endpointUrl: `${this._conn.instanceUrl}/services/Soap/m/${this._conn.version}`,
    });
    const res = await soapEndpoint.invoke(
      method,
      message,
      schema ? ({ result: schema } as SoapSchema) : undefined,
      ApiSchemas,
    );
    return res.result;
  }

  /**
   * Add one or more new metadata components to the organization.
   */
  create<
    M extends Metadata = Metadata,
    T extends MetadataType = MetadataType,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(type: T, metadata: MD[]): Promise<SaveResult[]>;
  create<
    M extends Metadata = Metadata,
    T extends MetadataType = MetadataType,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(type: T, metadata: MD): Promise<SaveResult>;
  create<
    M extends Metadata = Metadata,
    T extends MetadataType = MetadataType,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(type: T, metadata: MD | MD[]): Promise<SaveResult | SaveResult[]>;
  create(type: string, metadata: Metadata | Metadata[]) {
    const isArray = Array.isArray(metadata);
    metadata = assignTypeWithMetadata(metadata, type);
    const schema = isArray ? [ApiSchemas.SaveResult] : ApiSchemas.SaveResult;
    return this._invoke('createMetadata', { metadata }, schema);
  }

  /**
   * Read specified metadata components in the organization.
   */
  read<
    M extends Metadata = Metadata,
    T extends MetadataType = MetadataType,
    MD extends MetadataDefinition<T, M> = MetadataDefinition<T, M>
  >(type: T, fullNames: string[]): Promise<MD[]>;
  read<
    M extends Metadata = Metadata,
    T extends MetadataType = MetadataType,
    MD extends MetadataDefinition<T, M> = MetadataDefinition<T, M>
  >(type: T, fullNames: string): Promise<MD>;
  read<
    M extends Metadata = Metadata,
    T extends MetadataType = MetadataType,
    MD extends MetadataDefinition<T, M> = MetadataDefinition<T, M>
  >(type: T, fullNames: string | string[]): Promise<MD | MD[]>;
  async read(type: string, fullNames: string | string[]) {
    const ReadResultSchema =
      type in ApiSchemas
        ? ({
            type: ApiSchemas.ReadResult.type,
            props: {
              records: [type],
            },
          } as const)
        : ApiSchemas.ReadResult;
    const res: ReadResult = await this._invoke(
      'readMetadata',
      { type, fullNames },
      ReadResultSchema,
    );
    return Array.isArray(fullNames)
      ? res.records.map(deallocateTypeWithMetadata)
      : deallocateTypeWithMetadata(res.records[0]);
  }

  /**
   * Update one or more metadata components in the organization.
   */
  update<
    M extends Metadata = Metadata,
    T extends string = string,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(type: T, metadata: Array<Partial<MD>>): Promise<SaveResult[]>;
  update<
    M extends Metadata = Metadata,
    T extends string = string,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(type: T, metadata: Partial<MD>): Promise<SaveResult>;
  update<
    M extends Metadata = Metadata,
    T extends string = string,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(
    type: T,
    metadata: Partial<MD> | Array<Partial<MD>>,
  ): Promise<SaveResult | SaveResult[]>;
  update(type: string, metadata: Metadata | Metadata[]) {
    const isArray = Array.isArray(metadata);
    metadata = assignTypeWithMetadata(metadata, type);
    const schema = isArray ? [ApiSchemas.SaveResult] : ApiSchemas.SaveResult;
    return this._invoke('updateMetadata', { metadata }, schema);
  }

  /**
   * Upsert one or more components in your organization's data.
   */
  upsert<
    M extends Metadata = Metadata,
    T extends string = string,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(type: T, metadata: MD[]): Promise<UpsertResult[]>;
  upsert<
    M extends Metadata = Metadata,
    T extends string = string,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(type: T, metadata: MD): Promise<UpsertResult>;
  upsert<
    M extends Metadata = Metadata,
    T extends string = string,
    MD extends InputMetadataDefinition<T, M> = InputMetadataDefinition<T, M>
  >(type: T, metadata: MD | MD[]): Promise<UpsertResult | UpsertResult[]>;
  upsert(type: string, metadata: Metadata | Metadata[]) {
    const isArray = Array.isArray(metadata);
    metadata = assignTypeWithMetadata(metadata, type);
    const schema = isArray
      ? [ApiSchemas.UpsertResult]
      : ApiSchemas.UpsertResult;
    return this._invoke('upsertMetadata', { metadata }, schema);
  }

  /**
   * Deletes specified metadata components in the organization.
   */
  delete(type: string, fullNames: string[]): Promise<SaveResult[]>;
  delete(type: string, fullNames: string): Promise<SaveResult>;
  delete(
    type: string,
    fullNames: string | string[],
  ): Promise<SaveResult | SaveResult[]>;
  delete(type: string, fullNames: string | string[]) {
    const schema = Array.isArray(fullNames)
      ? [ApiSchemas.SaveResult]
      : ApiSchemas.SaveResult;
    return this._invoke('deleteMetadata', { type, fullNames }, schema);
  }

  /**
   * Rename fullname of a metadata component in the organization
   */
  rename(
    type: string,
    oldFullName: string,
    newFullName: string,
  ): Promise<SaveResult> {
    return this._invoke(
      'renameMetadata',
      { type, oldFullName, newFullName },
      ApiSchemas.SaveResult,
    );
  }

  /**
   * Retrieves the metadata which describes your organization, including Apex classes and triggers,
   * custom objects, custom fields on standard objects, tab sets that define an app,
   * and many other components.
   */
  describe(asOfVersion?: string): Promise<DescribeMetadataResult> {
    if (!asOfVersion) {
      asOfVersion = this._conn.version;
    }
    return this._invoke(
      'describeMetadata',
      { asOfVersion },
      ApiSchemas.DescribeMetadataResult,
    );
  }

  /**
   * Retrieves property information about metadata components in your organization
   */
  list(
    queries: ListMetadataQuery | ListMetadataQuery[],
    asOfVersion?: string,
  ): Promise<FileProperties[]> {
    if (!asOfVersion) {
      asOfVersion = this._conn.version;
    }
    return this._invoke('listMetadata', { queries, asOfVersion }, [
      ApiSchemas.FileProperties,
    ]);
  }

  /**
   * Checks the status of asynchronous metadata calls
   */
  checkStatus(asyncProcessId: string) {
    const res = this._invoke(
      'checkStatus',
      { asyncProcessId },
      ApiSchemas.AsyncResult,
    );
    return new AsyncResultLocator(this, res);
  }

  /**
   * Retrieves XML file representations of components in an organization
   */
  retrieve(request: Partial<RetrieveRequest>) {
    const res = this._invoke(
      'retrieve',
      { request },
      ApiSchemas.RetrieveResult,
    );
    return new RetrieveResultLocator(this, res);
  }

  /**
   * Checks the status of declarative metadata call retrieve() and returns the zip file contents
   */
  checkRetrieveStatus(asyncProcessId: string): Promise<RetrieveResult> {
    return this._invoke(
      'checkRetrieveStatus',
      { asyncProcessId },
      ApiSchemas.RetrieveResult,
    );
  }

  /**
   * Will deploy a recently validated deploy request
   *
   * @param options.id = the deploy ID that's been validated already from a previous checkOnly deploy request
   * @param options.rest = a boolean whether or not to use the REST API
   * @returns the deploy ID of the recent validation request
   */
  public async deployRecentValidation(options: {
    id: string;
    rest?: boolean;
  }): Promise<string> {
    const { id, rest } = options;
    let response: string;
    if (rest) {
      const messageBody = JSON.stringify({
        validatedDeployRequestId: id,
      });

      const requestInfo: HttpRequest = {
        method: 'POST',
        url: `${this._conn._baseUrl()}/metadata/deployRequest`,
        body: messageBody,
        headers: {
          'content-type': 'application/json',
        },
      };
      const requestOptions = { headers: 'json' };
      // This is the deploy ID of the deployRecentValidation response, not
      // the already validated deploy ID (i.e., validateddeployrequestid).
      // REST returns an object with an id property, SOAP returns the id as a string directly.
      response = (
        await this._conn.request<{ id: string }>(requestInfo, requestOptions)
      ).id;
    } else {
      response = await this._invoke('deployRecentValidation', {
        validationId: id,
      });
    }

    return response;
  }

  /**
   * Deploy components into an organization using zipped file representations
   * using the REST Metadata API instead of SOAP
   */
  deployRest(
    zipInput: Buffer,
    options: Partial<DeployOptions> = {},
  ): DeployResultLocator<S> {
    const form = new FormData();
    form.append('file', zipInput, {
      contentType: 'application/zip',
      filename: 'package.xml',
    });

    // Add the deploy options
    form.append('entity_content', JSON.stringify({ deployOptions: options }), {
      contentType: 'application/json',
    });

    const request: HttpRequest = {
      url: '/metadata/deployRequest',
      method: 'POST',
      headers: { ...form.getHeaders() },
      body: form.getBuffer(),
    };
    const res = this._conn.request<AsyncResult>(request);

    return new DeployResultLocator(this, res);
  }

  /**
   * Deploy components into an organization using zipped file representations
   */
  deploy(
    zipInput: Readable | Buffer | string,
    options: Partial<DeployOptions> = {},
  ): DeployResultLocator<S> {
    const res = (async () => {
      const zipContentB64 = await new Promise((resolve, reject) => {
        if (
          isObject(zipInput) &&
          'pipe' in zipInput &&
          typeof zipInput.pipe === 'function'
        ) {
          const bufs: Buffer[] = [];
          zipInput.on('data', (d) => bufs.push(d));
          zipInput.on('error', reject);
          zipInput.on('end', () => {
            resolve(Buffer.concat(bufs).toString('base64'));
          });
          // zipInput.resume();
        } else if (zipInput instanceof Buffer) {
          resolve(zipInput.toString('base64'));
        } else if (zipInput instanceof String || typeof zipInput === 'string') {
          resolve(zipInput);
        } else {
          throw 'Unexpected zipInput type';
        }
      });

      return this._invoke(
        'deploy',
        {
          ZipFile: zipContentB64,
          DeployOptions: options,
        },
        ApiSchemas.DeployResult,
      );
    })();

    return new DeployResultLocator(this, res);
  }

  /**
   * Checks the status of declarative metadata call deploy(), using either
   * SOAP or REST APIs. SOAP is the default.
   */
  async checkDeployStatus(
    asyncProcessId: string,
    includeDetails: boolean = false,
    rest: boolean = false,
  ): Promise<DeployResult> {
    if (rest) {
      const url = `/metadata/deployRequest/${asyncProcessId}${includeDetails ? '?includeDetails=true' : ''}`;
      type CheckDeployStatusRest = {
        id: string;
        validatedDeployRequestId: string | null;
        deployResult: DeployResult;
        deployOptions: DeployOptions | null;
      }
      return (await this._conn.requestGet<CheckDeployStatusRest>(url)).deployResult;
    } else {
      return this._invoke(
        'checkDeployStatus',
        {
          asyncProcessId,
          includeDetails,
        },
        ApiSchemas.DeployResult,
      );
    }
  }

  async cancelDeploy(id: string): Promise<CancelDeployResult>{
    return this._invoke('cancelDeploy', { id })
  }
}

/*--------------------------------------------*/

/**
 * The locator class for Metadata API asynchronous call result
 */
export class AsyncResultLocator<
  S extends Schema,
  R extends {} = AsyncResult
> extends EventEmitter {
  _meta: MetadataApi<S>;
  _promise: Promise<AsyncResult>;
  _id: string | undefined;

  /**
   *
   */
  constructor(meta: MetadataApi<S>, promise: Promise<AsyncResult>) {
    super();
    this._meta = meta;
    this._promise = promise;
  }

  /**
   * Promise/A+ interface
   * http://promises-aplus.github.io/promises-spec/
   *
   * @method Metadata~AsyncResultLocator#then
   */
  then<U, V>(
    onResolve?: ((result: AsyncResult) => U | Promise<U>) | null | undefined,
    onReject?: ((err: Error) => V | Promise<V>) | null | undefined,
  ): Promise<U | V> {
    return this._promise.then(onResolve, onReject);
  }

  /**
   * Check the status of async request
   */
  async check() {
    const result = await this._promise;
    this._id = result.id;
    return this._meta.checkStatus(result.id);
  }

  /**
   * Polling until async call status becomes complete or error
   */
  poll(interval: number, timeout: number) {
    const startTime = new Date().getTime();
    const poll = async () => {
      try {
        const now = new Date().getTime();
        if (startTime + timeout < now) {
          let errMsg = 'Polling time out.';
          if (this._id) {
            errMsg += ' Process Id = ' + this._id;
          }
          this.emit('error', new Error(errMsg));
          return;
        }
        const result = await this.check();
        if (result.done) {
          this.emit('complete', result);
        } else {
          this.emit('progress', result);
          setTimeout(poll, interval);
        }
      } catch (err) {
        this.emit('error', err);
      }
    };
    setTimeout(poll, interval);
  }

  /**
   * Check and wait until the async requests become in completed status
   */
  complete() {
    return new Promise<R>((resolve, reject) => {
      this.on('complete', resolve);
      this.on('error', reject);
      this.poll(this._meta.pollInterval, this._meta.pollTimeout);
    });
  }
}

/*--------------------------------------------*/
/**
 * The locator class to track retreive() Metadata API call result
 */
export class RetrieveResultLocator<S extends Schema> extends AsyncResultLocator<
  S,
  RetrieveResult
> {
  /**
   * Poll checkRetrieveStatus() until the retrieve operation completes,
   * then return the RetrieveResult.
   */
  async complete() {
    if (!this._id) {
      const asyncResult = await this._promise;
      this._id = asyncResult.id;
    }
    const id = this._id;

    return new Promise<RetrieveResult>((resolve, reject) => {
      const startTime = new Date().getTime();

      const poll = async () => {
        try {
          const now = new Date().getTime();
          if (startTime + this._meta.pollTimeout < now) {
            const err = new Error('Polling time out. Retrieve operation is not completed.');
            this.emit('error', err);
            reject(err);
            return;
          }
          const result = await this._meta.checkRetrieveStatus(id);
          if (result.done) {
            this.emit('complete', result);
            resolve(result);
          } else {
            this.emit('progress', result);
            setTimeout(poll, this._meta.pollInterval);
          }
        } catch (err) {
          this.emit('error', err);
          reject(err);
        }
      };

      setTimeout(poll, this._meta.pollInterval);
    });
  }

  /**
   * Change the retrieved result to Node.js readable stream
   */
  stream() {
    const resultStream = new Readable();
    let reading = false;
    resultStream._read = async () => {
      if (reading) {
        return;
      }
      reading = true;
      try {
        const result = await this.complete();
        resultStream.push(Buffer.from(result.zipFile, 'base64'));
        resultStream.push(null);
      } catch (e) {
        resultStream.emit('error', e);
      }
    };
    return resultStream;
  }
}

/*--------------------------------------------*/
/**
 * The locator class to track deploy() Metadata API call result
 *
 * @protected
 * @class Metadata~DeployResultLocator
 * @extends Metadata~AsyncResultLocator
 * @param {Metadata} meta - Metadata API object
 * @param {Promise.<Metadata~AsyncResult>} result - Promise object for async result of deploy() call
 */
export class DeployResultLocator<S extends Schema> extends AsyncResultLocator<
  S,
  DeployResult
> {
  /**
   * Poll checkDeployStatus() until the deploy operation completes,
   * then return the DeployResult.
   */
  async complete(includeDetails?: boolean) {
    if (!this._id) {
      const asyncResult = await this._promise;
      this._id = asyncResult.id;
    }
    const id = this._id;

    return new Promise<DeployResult>((resolve, reject) => {
      const startTime = new Date().getTime();

      const poll = async () => {
        try {
          const now = new Date().getTime();
          if (startTime + this._meta.pollTimeout < now) {
            const err = new Error('Polling time out. Deploy operation is not completed.');
            this.emit('error', err);
            reject(err);
            return;
          }
          const result = await this._meta.checkDeployStatus(id, includeDetails);
          if (result.done) {
            this.emit('complete', result);
            resolve(result);
          } else {
            this.emit('progress', result);
            setTimeout(poll, this._meta.pollInterval);
          }
        } catch (err) {
          this.emit('error', err);
          reject(err);
        }
      };

      setTimeout(poll, this._meta.pollInterval);
    });
  }
}

/*--------------------------------------------*/
/*
 * Register hook in connection instantiation for dynamically adding this API module features
 */
registerModule('metadata', (conn) => new MetadataApi(conn));

export default MetadataApi;
