/**
 * Mongo DB DAO for program server
 *
 *  Copyright (C) 2010-2019 R-T Specialty, LLC.
 *
 *  This file is part of the Liza Data Collection Framework.
 *
 *  liza is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU Affero General Public License as
 *  published by the Free Software Foundation, either version 3 of the
 *  License, or (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU Affero General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

import {ServerDao, Callback} from './ServerDao';
import {PositiveInteger} from '../../numeric';
import {DocumentId, QuoteId} from '../../document/Document';
import {ServerSideQuote, FieldState} from '../quote/ServerSideQuote';
import {WorksheetData} from '../rater/Rater';
import {NoPendingError} from '../../error/NoPendingError';
import {Collection, MongoError, UpdateOneOptions} from 'mongodb';
import {EventEmitter} from 'events';

type ErrorCallback = (err: NullableError) => void;

/** Bucket data directly from the database */
export type RawBucketData = Record<string, any>;

/**
 * Document ("quote") record
 *
 * This structure was poorly planned and needs both cleanup (much of it is
 * unnecessary or does not belong) and restructuring.
 */
export type DocumentData = {
  agentEntityId?: number;
  agentId?: number;
  agentName?: string;
  boundInd?: 0 | 1;
  creditScoreRef?: string;
  currentStepId?: number;
  data?: RawBucketData;
  error?: string;
  explicitLock?: string;
  explicitLockStepId?: number;
  importDirty?: 0 | 1;
  importedInd?: 0 | 1;
  initialRatedDate?: UnixTimestamp;
  lastPremDate?: UnixTimestamp;
  meta?: RawBucketData;
  pver?: string;
  ratedata?: Record<string, any>;
  retryAttempts?: number;
  startDate: UnixTimestamp;
  topSavedStepId?: number;
  topVisitedStepId?: number;
  fieldState?: FieldState;
};

export class ServerDaoSaveError extends Error {
  /**
   * @param message - error message
   * @param id      - data model's ID
   */
  constructor(message: string, public readonly id: number) {
    super(message);
    Object.setPrototypeOf(this, new.target.prototype);
    this.name = ServerDaoSaveError.name;
  }
}

/**
 * Convert a callback-based ServerDao to be a promise-based
 *
 * @param dao - relevant DAO
 *
 * @return promise-based API
 */
export const asyncServerDao = (dao: MongoServerDao) => {
  return {
    /**
     * Save the quote
     *
     * @param quote - quote
     * @param data  - saves this data instead of the bucket
     * @param push_data - quote data to push
     *
     * @return promise with quote
     */
    async saveQuote(
      quote: ServerSideQuote,
      data?: CommonObject,
      push_data?: CommonObject
    ): Promise<ServerSideQuote> {
      const on_failure = () => {
        throw new ServerDaoSaveError('Failed to save quote.', quote.getId());
      };

      return new Promise(resolve => {
        dao.saveQuote(quote, resolve, on_failure, data, push_data);
      });
    },

    /**
     * Save the quote metabucket directly
     *
     * @param quote    - quote
     * @param new_meta - saves this data instead of the metabucket
     *
     * @return promise with quote
     */
    async saveQuoteMetaDirect(
      quote: ServerSideQuote,
      new_meta?: CommonObject
    ): Promise<ServerSideQuote> {
      const on_failure = () => {
        throw new ServerDaoSaveError(
          'Failed to save quote meta.',
          quote.getId()
        );
      };

      return new Promise(resolve => {
        dao.saveQuoteMetaDirect(quote, new_meta, resolve, on_failure);
      });
    },
  };
};

/**
 * Uses MongoDB as a data store
 */
export class MongoServerDao extends EventEmitter implements ServerDao {
  /** Collection used to store quotes */
  readonly COLLECTION: string = 'quotes';

  /** Sequence (auto-increment) collection */
  readonly COLLECTION_SEQ: string = 'seq';

  /** Sequence key for quote ids */
  readonly SEQ_QUOTE_ID: string = 'quoteId';

  /** Sequence quoteId default */
  readonly SEQ_QUOTE_ID_DEFAULT: number = 200000;

  /** Whether the DAO is initialized and ready to be used */
  private _ready = true;

  /** The document fields to read */
  readonly PULL_FIELDS: Record<string, number> = {
    agentEntityId: 1,
    agentId: 1,
    agentName: 1,
    boundInd: 1,
    creditScoreRef: 1,
    currentStepId: 1,
    data: 1,
    error: 1,
    explicitLock: 1,
    explicitLockStepId: 1,
    importDirty: 1,
    importedInd: 1,
    initialRatedDate: 1,
    lastPremDate: 1,
    meta: 1,
    pver: 1,
    ratedata: 1,
    retryAttempts: 1,
    startDate: 1,
    topSavedStepId: 1,
    topVisitedStepId: 1,
    fieldState: 1,
  };

  readonly nonpersistent_data_keys = ['__internal_user'];

  /**
   * Initializes DAO
   *
   * @param _quote_collection - collection to save data to
   * @param _seq_collection   - collection to read sequences (auto-increments)
   * @param _env              - the name of the current environment
   * @param _ts_ctor          - a timestamp constructor
   */
  constructor(
    private readonly _quote_collection: Collection,
    private readonly _seq_collection: Collection,
    private readonly _env: string,
    private readonly _ts_ctor: () => UnixTimestamp
  ) {
    super();
  }

  /**
   * Initializes error events and attempts to connect to the database
   *
   * connectError event will be emitted on failure.
   *
   * @param success - function to call when connection is complete
   *                          (will not be called if connection fails)
   */
  init(success: () => void): this {
    this._seq_collection
      .find<any>({_id: this.SEQ_QUOTE_ID})
      .limit(1)
      .next((err: MongoError, data) => {
        if (err || !data) {
          this._initQuoteIdSeq(success);
          this.emit('ready');
          return;
        }

        if (success instanceof Function) {
          success();
        }
      });

    return this;
  }

  /**
   * Initialize the quote id sequence collection
   *
   * @param success - a function to call once it has been initialized
   */
  private _initQuoteIdSeq(success: () => void) {
    this._seq_collection.insertOne(
      {
        _id: this.SEQ_QUOTE_ID,
        val: this.SEQ_QUOTE_ID_DEFAULT,
      },
      (err: NullableError) => {
        if (err) {
          this.emit('seqError', err);
          return;
        }

        this.emit('seqInit', this.SEQ_QUOTE_ID);

        success();
      }
    );
  }

  /**
   * Saves a quote to the database
   *
   * A full save will include all metadata.  This should not cause any
   * problems with race conditions for pending Data API calls on meta
   * fields because those results write to individual indexes and do not
   * rely on existing data.
   *
   * @param quote         - the quote to save
   * @param success       - function to call on success
   * @param failure       - function to call if save fails
   * @param save_data     - quote data to save (optional)
   * @param push_data     - quote data to push (optional)
   * @param force_publish - reset published indicator (optional)
   */
  saveQuote(
    quote: ServerSideQuote,
    success: Callback = () => {},
    failure: Callback = () => {},
    save_data?: any,
    push_data?: any,
    force_publish = true
  ): this {
    // if we're not ready, then we can't save the quote!
    if (this._ready === false) {
      this.emit(
        'saveQuoteError',
        {message: 'Database server not ready'},
        Error('Database not ready'),
        quote
      );

      failure.call(this, quote);
      return this;
    }

    if (save_data === undefined) {
      save_data = {};

      const quote_data = quote.getBucket().getData();

      Object.keys(quote_data).forEach(
        key => (save_data['data.' + key] = quote_data[key])
      );
    }

    const id = quote.getId();
    const save_ts = this._ts_ctor();

    // some data should always be saved because the quote will be created if
    // it does not yet exist
    save_data.id = id;
    save_data.env = this._env;
    save_data.pver = quote.getProgramVersion();
    save_data.importDirty = 1;
    save_data.lastPremDate = quote.getLastPremiumDate();
    save_data.retryAttempts = quote.getRetryAttempts();
    save_data.initialRatedDate = quote.getRatedDate();
    save_data.explicitLock = quote.getExplicitLockReason();
    save_data.explicitLockStepId = quote.getExplicitLockStep();
    save_data.importedInd = +quote.isImported();
    save_data.boundInd = +quote.isBound();
    save_data.lastUpdate = save_ts;
    save_data.quoteSetId = id;

    // Attempt to set these fields if they have not been provided ad hoc
    save_data.startDate = save_data.startDate ?? quote.getStartDate();
    save_data.agentId = '' + (save_data.agentId ?? quote.getAgentId());
    save_data.agentName = save_data.agentName ?? quote.getAgentName();
    save_data.agentEntityId =
      save_data.agentEntityId ?? quote.getAgentEntityId();

    if (force_publish) {
      save_data.publishResetTs = save_ts;
      save_data.published = false;
    }

    const exp_date = quote.getExpirationDate();
    save_data.quoteExpDate = exp_date === Infinity ? 0 : exp_date;

    // meta will eventually take over for much of the above data
    save_data['meta.liza_timestamp_initial_rated'] = [quote.getRatedDate()];
    const last_updated_by = quote.getUserName();
    if (last_updated_by !== '') {
      save_data['meta.last_updated_by_username'] = [last_updated_by];
    }

    // save the stack so we can track this call via the oplog
    save_data._stack = new Error().stack;

    // only set created by on initial document insert
    const insert_only = {
      'meta.created_by_username': [quote.getUserName()],
      'meta.retail_agency': [quote.getAgencyNumber()],
    };

    // only set bind username on concluding save
    if (quote.isImported() && last_updated_by !== '') {
      save_data['meta.bound_by_username'] = [quote.getUserName()];
    }

    save_data = this.filterNonpersistent(save_data);

    // do not push empty objects
    const document =
      !push_data || !Object.keys(push_data).length
        ? {$set: save_data, $setOnInsert: insert_only}
        : {$set: save_data, $setOnInsert: insert_only, $push: push_data};

    // update the quote data if it already exists (same id), otherwise
    // insert it
    this._quote_collection.updateOne(
      {id: id},
      document,

      // create record if it does not yet exist
      {upsert: true},

      // on complete
      err => {
        // if an error occurred, then we cannot continue
        if (err) {
          this.emit('saveQuoteError', err, quote);

          // let the caller handle the error
          if (failure instanceof Function) {
            failure(quote);
          }

          return;
        }

        // successful
        if (success instanceof Function) {
          success.call(this, quote);
        }
      }
    );

    return this;
  }

  /**
   * Merges quote data with the existing (rather than overwriting)
   *
   * @param quote   - quote to save
   * @param data    - quote data
   * @param success - successful callback
   * @param failure - failure callback
   * @param options - mongo options to specify
   */
  mergeData(
    quote: ServerSideQuote,
    data: Record<string, any>,
    success: Callback = () => {},
    failure: Callback = () => {},
    options: UpdateOneOptions = {}
  ): this {
    if (!data || !Object.keys(data).length) {
      success(quote);
      return this;
    }

    // we do not want to alter the original data; use it as a prototype
    const update = this.filterNonpersistent(data);

    // save the stack so we can track this call via the oplog
    this._quote_collection.updateOne(
      {id: quote.getId()},
      {$set: update},
      options,

      (err: MongoError) => {
        if (err) {
          this.emit('saveQuoteError', err, quote);

          if (typeof failure === 'function') {
            failure(quote);
          }

          return;
        }

        if (typeof success === 'function') {
          success(quote);
        }
      }
    );

    return this;
  }

  /**
   * Filter out nonpersistent data
   *
   * FIXME: This is a temporary solution until nonpersistent fields are defined
   * elsewhere in a more formal way
   *
   * @param data The bucket data to filter
   */
  filterNonpersistent(data: Record<string, any>): Record<string, any> {
    for (const key of this.nonpersistent_data_keys) {
      delete data['data.' + key];
    }

    return data;
  }

  /**
   * Merges bucket data with the existing bucket (rather than overwriting the
   * entire bucket)
   *
   * @param quote   - quote to save
   * @param data    - bucket data
   * @param success - successful callback
   * @param failure - failure callback
   */
  mergeBucket(
    quote: ServerSideQuote,
    data: Record<string, any>,
    success: Callback = () => {},
    failure: Callback = () => {}
  ): this {
    const update: Record<string, any> = {};

    for (const field in data) {
      if (!field) {
        continue;
      }

      update['data.' + field] = data[field];
    }

    return this.mergeData(quote, update, success, failure);
  }

  /**
   * Saves the quote state to the database
   *
   * The quote state includes the current step, the top visited step and the
   * explicit lock message.
   *
   * @param quote   - the quote to save
   * @param success - function to call on success
   * @param failure - function to call if save fails
   */
  saveQuoteState(
    quote: ServerSideQuote,
    success: Callback = () => {},
    failure: Callback = () => {}
  ): this {
    const update: Record<string, any> = {
      currentStepId: quote.getCurrentStepId(),
      topVisitedStepId: quote.getTopVisitedStepId(),
      topSavedStepId: quote.getTopSavedStepId(),
    };

    const field_state = quote.getFieldState();

    // Do not wipe out field state if it's not provided (having
    // getFieldState run the classifier automatically would be dangerous
    // since it is so expensive)
    if (field_state && Object.keys(field_state).length > 0) {
      update.fieldState = field_state;
    }

    return this.mergeData(quote, update, success, failure);
  }

  /**
   * Ensure the quote has pending rates
   *
   * @param quote - the quote to validate
   *
   * @returns a promise with the quote
   */
  ensurePendingRate(quote: ServerSideQuote): Promise<ServerSideQuote> {
    return new Promise<ServerSideQuote>((resolve, reject) => {
      this._quote_collection
        .find<any>({id: quote.getId()})
        .limit(1)
        .project({'ratedata.__rate_pending': 1})
        .next((err: MongoError, data) => {
          // was the quote found?
          if (!data || err) {
            // Return the quote unchanged
            reject(new Error('Could not find quote ' + quote.getId()));
            return;
          }

          const ratedata = data.ratedata;

          if (!ratedata) {
            reject(
              new NoPendingError('No prior rate for quote ' + quote.getId())
            );
            return;
          }

          if (!ratedata.__rate_pending || ratedata.__rate_pending <= 0) {
            reject(
              new NoPendingError('No pending rates for quote ' + quote.getId())
            );
            return;
          }

          resolve(quote);
        });
    });
  }

  /**
   * Sets whether the quote is published to the retail agent
   *
   * if published the liza_access_group is set to the retail agency on the quote
   * if unpublished the liza_access_group is cleared
   *
   * @param quote    - destination quote
   * @param publish  - true or false
   */
  publishQuote(quote: ServerSideQuote, publish: boolean): Promise<any> {
    const update: Record<string, any> = {};
    const meta = quote.getMetabucket().getData();

    update['meta.liza_timestamp_owner_vis_updated'] = [this._ts_ctor()];

    if (publish === true) {
      update['meta.liza_access_groups'] =
        meta.retail_agency === undefined ? undefined : meta.retail_agency;
    } else {
      update['meta.liza_access_groups'] = [];
    }

    return this._quote_collection.updateOne(
      {id: quote.getId()},
      {$set: update}
    );
  }

  /**
   * Save document metadata (meta field on document)
   *
   * Only the provided indexes will be modified (that is---data will be
   * merged with what is already in the database).
   *
   * @param quote    - destination quote
   * @param new_meta - bucket-formatted data to write
   * @param success  - callback on success
   * @param failure  - callback on error
   * @param options  - mongo options to specify
   */
  saveQuoteMeta(
    quote: ServerSideQuote,
    new_meta?: Record<string, any>,
    success: Callback = () => {},
    failure: Callback = () => {},
    options: UpdateOneOptions = {}
  ): void {
    const update: Record<string, any> = {};

    new_meta = new_meta || quote.getMetabucket().getData();

    for (const key in new_meta) {
      const meta = new_meta[key];

      for (const i in meta) {
        update['meta.' + key + '.' + i] = new_meta[key][i];
      }
    }

    this.mergeData(quote, update, success, failure, options);
  }

  /**
   * Save document metadata directly
   *
   * This saves data directly to the top-level keys of the meta bucket and does
   * not iterate over any values that are iterable. These values will be saved
   * as-is.
   *
   * TODO: Replace saveQuoteMeta with this method when bugs related to not
   * iterating over inner-indexes are alleviated.
   *
   * @param quote    - destination quote
   * @param new_meta - bucket-formatted data to write
   * @param success  - callback on success
   * @param failure  - callback on error
   * @param options  - mongo options to specify
   */
  saveQuoteMetaDirect(
    quote: ServerSideQuote,
    new_meta?: Record<string, any>,
    success: Callback = () => {},
    failure: Callback = () => {},
    options: UpdateOneOptions = {}
  ): void {
    const update: Record<string, any> = {};

    new_meta = new_meta || quote.getMetabucket().getData();

    for (const key in new_meta) {
      update['meta.' + key] = new_meta[key];
    }

    this.mergeData(quote, update, success, failure, options);
  }

  /**
   * Saves the quote lock state to the database
   *
   * @param quote   - the quote to save
   * @param success - function to call on success
   * @param failure - function to call if save fails
   */
  saveQuoteLockState(
    quote: ServerSideQuote,
    success: Callback = () => {},
    failure: Callback = () => {}
  ): this {
    // lock state is saved by default
    return this.saveQuote(quote, success, failure, {});
  }

  /**
   * Pulls quote data from the database
   *
   * @param quote_id - id of quote
   */
  pullQuote(quote_id: DocumentId): Promise<DocumentData | null> {
    return new Promise((resolve, reject) => {
      this._quote_collection
        .find<DocumentData>({id: quote_id})
        .limit(1)
        .project(this.PULL_FIELDS)
        .next((err, data) => {
          if (err) {
            return reject(err);
          }

          // was the quote found?
          if (!data) {
            return resolve(null);
          }

          // return the quote data
          resolve(data);
        });
    });
  }

  /**
   * Returns the minimum allowable quote id
   *
   * @param callback - function to call with minimum quote id
   */
  getMinQuoteId(callback: (min_id: number) => void): this {
    // just in case it's asynchronous later on
    callback.call(this, this.SEQ_QUOTE_ID_DEFAULT);

    return this;
  }

  /**
   * Returns the current highest quote id
   *
   * @param callback - function to call with current highest quote id
   */
  getMaxQuoteId(callback: (max_id: number) => void): void {
    this._seq_collection
      .find({_id: this.SEQ_QUOTE_ID})
      .limit(1)
      .next((err, data) => {
        if (!data || err) {
          callback(0);
          return;
        }

        // return the max quote id
        callback(data.val);
      });
  }

  /**
   * Returns the next available quote id
   *
   * @param callback - function to call with next available quote id
   */
  getNextQuoteId(): Promise<number> {
    return new Promise<number>((accept, reject) => {
      this._seq_collection.findOneAndUpdate(
        {_id: this.SEQ_QUOTE_ID},
        {$inc: {val: 1}},
        {returnOriginal: false, sort: {val: -1}},
        (err: any, result: any) => {
          if (err || !result.value) {
            this.emit('seqError', err);

            reject(err);
            return;
          }

          // return the new id
          accept(result.value.val);
        }
      );
    });
  }

  /**
   * Create a new revision with the provided quote data
   *
   * The revision will contain the whole the quote. If space is a concern, we
   * can (in the future) calculate a delta instead (Mike recommends the Git
   * model of storing the deltas in previous revisions and the whole of the
   * bucket in the most recently created revision).
   *
   * @param quote    - the quote to create a revision with
   * @param callback - function to call on error
   */
  createRevision(quote: ServerSideQuote, callback: ErrorCallback): void {
    this._quote_collection.updateOne(
      {id: quote.getId()},
      {$push: {revisions: {data: quote.getBucket().getData()}}},
      {upsert: true},
      callback
    );
  }

  /**
   * Get a quote revision by its revision id
   *
   * @param quote    - the quote
   * @param revid    - the revision id
   * @param callback - a function to call with results
   */
  getRevision(
    quote: ServerSideQuote,
    revid: PositiveInteger,
    callback: ErrorCallback
  ): void {
    this._quote_collection
      .find({id: quote.getId()})
      .limit(1)
      .next((err, data) => {
        // was the quote found?
        if (err || !data || data.revisions.length < +revid + 1) {
          callback(null);
          return;
        }

        // return the quote data
        callback(data.revisions[+revid]);
      });
  }

  /**
   * Set worksheet data
   *
   * @param qid      - The quote id
   * @param data     - worksheet data
   * @param callback - a function to call on error
   */
  setWorksheets(
    qid: QuoteId,
    data: Record<string, any>,
    callback: ErrorCallback
  ): void {
    this._quote_collection.updateOne(
      {id: qid},
      {$set: {worksheets: {data: data}}},
      {upsert: true},
      callback
    );
  }

  /**
   * Retrieve worksheet data for a given quote id and supplier
   *
   * @param qid      - the quote id
   * @param supplier - the supplier to retrieve the worksheet for
   * @param index    - the worksheet index
   *
   * @return Promise with worksheet data
   */
  getWorksheet(
    qid: QuoteId,
    supplier: string,
    index: PositiveInteger
  ): Promise<WorksheetData> {
    return new Promise<WorksheetData>((resolve, reject) => {
      this._quote_collection
        .find({id: qid})
        .limit(1)
        .project({'worksheets.data': 1})
        .next((err, result) => {
          if (err) {
            reject(err);
          }

          // was the quote found?
          if (
            !result ||
            !result.worksheets ||
            !result.worksheets.data ||
            !result.worksheets.data[supplier]
          ) {
            reject('Worksheet data not found');
            return;
          }

          resolve({
            data: result.worksheets.data[supplier][index],
          });
        });
    });
  }
}
