/**
 * Loads documents into memory
 *
 *  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 * as A from 'fp-ts/Array';
import * as B from 'fp-ts/Bounded';
import * as E from 'fp-ts/Either';
import * as IOE from 'fp-ts/IOEither';
import * as IO from 'fp-ts/IO';
import * as M from 'fp-ts/Monoid';
import * as O from 'fp-ts/Option';
import * as R from 'fp-ts/Record';
import * as TE from 'fp-ts/TaskEither';

import {pipe, flow, constant, not} from 'fp-ts/function';
import {between, ordNumber, contramap} from 'fp-ts/Ord';

const {isArray} = Array;

import {DocumentId} from '../../document/Document';
import {Program} from '../../program/Program';
import {
  MongoServerDao,
  DocumentData,
  RawBucketData,
} from '../db/MongoServerDao';
import {ServerSideQuote, FieldState} from './ServerSideQuote';
import {UserSession} from '../request/UserSession';
import {ClassData} from '../../client/Cmatch';
import {invalidate} from '../../validate/invalid';

/** Pull a document from a datasource using a DAO */
export const pullDocumentFromDao = (dao: MongoServerDao) =>
  flow(
    TE.tryCatchK(dao.pullQuote.bind(dao), String),
    TE.chainEitherKW(E.fromNullable('Quote not found'))
  );

export const applyDocumentDefaults = (program: Program) => (
  doc_data: DocumentData
) => () => {
  const defaulted = applyBucketDefaults(program)(doc_data.data || {})();
  doc_data.data = defaulted;
  return doc_data;
};

/**
 * Retrieve next available quote id from DB, with side-effects
 *
 * This will increment the counter in the database atomically.
 *
 * Other languages have conventions for side-effect-laden functions (e.g. a
 * '!' suffix in Scheme), which we don't have here, so this is instead
 * verbose to try to indicate what it's doing.
 */
export const incrementAndPersistNextQuoteId = (dao: MongoServerDao) =>
  TE.tryCatch(dao.getNextQuoteId.bind(dao), E.toError);

/**
 * Apply default bucket data for the given program
 *
 * This is a near-verbatim migration of what was `ProgramInit`; it still
 * needs to be converted into a more functional style.
 */
export const applyBucketDefaults = (program: Program) => (
  data: RawBucketData
): IO.IO<RawBucketData> => () => {
  const {
    defaults = {},
    meta: {groups = {}},
  } = program;

  Object.keys(program.groupExclusiveFields).forEach(group => {
    let i = program.groupExclusiveFields[group].length;

    while (i--) {
      const field = program.groupExclusiveFields[group][i];

      const init_value = defaults[field];

      // generated questions with no types should never be part of
      // the bucket
      if (!program.hasKnownType(field)) {
        continue;
      }

      if (defaults[field] === undefined) {
        continue;
      }

      // If no document data, initialize with default value
      if (data[field] === undefined) {
        data[field] = [init_value];
      }

      // If min rows on the group is greater than the data
      // currently in the bucket, then populate the rest
      // of the data with the default data until the
      // arrays are the same length
      if (
        groups[group] !== undefined &&
        data[field].length < groups[group].min
      ) {
        let index = data[field].length;

        while (index < groups[group].min) {
          data[field][index] = init_value;
          index++;
        }
      }
    }
  });

  return data;
};

/**
 * Populate a document (quote) with session data, giving precedence to data
 * already present on the document
 */
export const loadSessionIntoQuote = (session: UserSession) => (
  quote: ServerSideQuote
) => (data: DocumentData): IO.IO<ServerSideQuote> => () =>
  quote
    .setAgentId(data.agentId || session.agentId() || 0)
    .setUserName(session.userName() || '')
    .setAgencyNumber(session.agencyNumber() || '')
    .setAgentName(data.agentName || session.agentName() || '')
    .setData({__internal_user: [session.isInternal() ? '1' : '0']});

/**
 * Populate a document (quote) with data returned from the database
 */
export const loadDocumentIntoQuote = (quote: ServerSideQuote) => (
  quote_data: DocumentData
): IO.IO<ServerSideQuote> => () =>
  quote
    .setData(quote_data.data || {})
    .setLastPersistedFieldState(quote_data.fieldState || undefined)
    .setMetadata(quote_data.meta || {})
    .setAgentEntityId(quote_data.agentEntityId || 0)
    .setInitialRatedDate(quote_data.initialRatedDate || 0)
    .setStartDate(quote_data.startDate || 0)
    .setImported(!!quote_data.importedInd)
    .setBound(!!quote_data.boundInd)
    .needsImport(!!quote_data.importDirty)
    .setCurrentStepId(quote_data.currentStepId || 0)
    .setTopVisitedStepId(quote_data.topVisitedStepId || 0)
    .setTopSavedStepId(quote_data.topSavedStepId || 0)
    .setProgramVersion(quote_data.pver || '')
    .setExplicitLock(
      quote_data.explicitLock || '',
      quote_data.explicitLockStepId || 0
    )
    .setError(quote_data.error || '')
    .setCreditScoreRef(quote_data.creditScoreRef || '')
    .setLastPremiumDate(quote_data.lastPremDate || <UnixTimestamp>0)
    .setRatedDate(quote_data.initialRatedDate || <UnixTimestamp>0)
    .setRatingData(quote_data.ratedata || {})
    .setRetryAttempts(quote_data.retryAttempts || 0);

/**
 * Merge two sets of document data
 *
 * The second data set's members take priority over the first's.
 **/
export const mergeDocumentData = (sub_data: DocumentData) => (
  super_data: DocumentData
): DocumentData => {
  return {...sub_data, ...super_data};
};

/** Load data into a document (quote) */
export const loadDocument = (
  pullDocument: (id: DocumentId) => TE.TaskEither<unknown, DocumentData>
) => (program: Program) => (session: UserSession) => (
  quote: ServerSideQuote
) => (init_doc_data: DocumentData): TE.TaskEither<unknown, ServerSideQuote> =>
  pipe(
    quote.getId(),
    pullDocument,
    TE.chain(flow(applyDocumentDefaults(program), TE.fromIO)),
    TE.chainFirst(flow(loadSessionIntoQuote(session)(quote), TE.fromIO)),
    TE.chain(
      flow(
        mergeDocumentData(init_doc_data),
        loadDocumentIntoQuote(quote),
        TE.fromIO
      )
    )
  );

/** Load and initialize an existing document */
export const initExistingDocument = flow(pullDocumentFromDao, loadDocument);

const processNaFields = (program: Program) => (data: DocumentData) => () =>
  program.processNaFields(data.data || {});

/** Prepare initial data for a new document (quote) */
export const prepareNewDocument = (program: Program) =>
  pipe(
    applyDocumentDefaults(program)(<DocumentData>{}),

    TE.fromIO,
    TE.chainFirst(flow(processNaFields(program), TE.fromIO))
  );

/** Set system-level fields on the bucket */
const setSystemFields = (
  quote: ServerSideQuote
): IO.IO<ServerSideQuote> => () =>
  quote.setData({
    __quote_id: [quote.getId()],
    __document_id: [quote.getId()],
  });

/** Initialize a new document that has not yet been persisted */
export const initNewDocument = (dao: MongoServerDao) => (
  quote_new: (id: number) => ServerSideQuote
) => (program: Program) => (session: UserSession) => (
  init_doc_data: DocumentData
) =>
  pipe(
    incrementAndPersistNextQuoteId(dao),
    TE.map(quote_new),
    TE.chain(flow(setSystemFields, TE.fromIO)),
    TE.chain(quote =>
      loadDocument(() => prepareNewDocument(program))(program)(session)(quote)(
        init_doc_data
      )
    )
  );

export const documentUsesProgram = (program: Program) => (
  quote: ServerSideQuote
) => quote.getProgramId() === program.getId();

/** Whether the automatic step kickback system is enabled */
const featureKickbackEnabled = (quote: ServerSideQuote) =>
  quote.getDataByName('__feature_pver_kickback')?.[0] === '1';

/**
 * The step id that should be considered when determining whether a user
 * ought to be kicked back on program version change
 *
 * TODO: Remove flag after testing
 */
const kickbackStepConsideration = (quote: ServerSideQuote) =>
  quote.getDataByName('__feature_pver_kickback_topsaved')?.[0] === '1'
    ? quote.getTopSavedStepId()
    : quote.getCurrentStepId();

/**
 * Field state as of when the user last interacted with the document
 *
 * If the feature is not enabled, this will return the _current_ field
 * state, which will effectively result in a noop if kickback logic is
 * run.  Kickback logic should _not_ be run, since the flag applies there as
 * well, but this value is safe in that, if there happens to be a bug where
 * it is run, we won't wipe out a ton of user data.
 *
 * Note that an empty object means that the user sees no fields.  This could
 * be a legitimate scenario (e.g. maybe a static message is shown), but it's
 * important that we do not act on it if it's not legitimate, since that
 * would cause all applicable fields to be set to an invalid state.
 *
 * If no prior field state can be found, that means that the quote predates
 * the existence of persisted field state.  Rather than failing, we attempt
 * to infer what the prior field state must have been the best we can.
 */
const lastPersistedFieldState = (program: Program) => (
  quote: ServerSideQuote
): E.Either<Error, FieldState> =>
  pipe(
    featureKickbackEnabled(quote)
      ? quote.getLastPersistedFieldState()
      : quote.getFieldState(),
    E.fromNullable(Error('Last field state is not available')),

    // TODO: This line (and associated functions) can be removed at some
    // point, at the expense of old documents without field state no longer
    // being accessible (the above error will be returned).
    E.orElse(() => inferPriorFieldState(program)(quote))
  );

/**
 * Infer prior state in absence of explicit field state
 *
 * This represents a best guess based on current bucket values and whether
 * fields are currently required.  For more information, see
 * `inferPriorFieldApplicable`.
 */
const inferPriorFieldState = (program: Program) => (
  quote: ServerSideQuote
): E.Either<never, FieldState> =>
  pipe(
    program.qstep,
    R.filter(between(ordNumber)(0, kickbackStepConsideration(quote))),
    R.mapWithIndex(quote.getDataByName.bind(quote)),
    R.mapWithIndex(inferPriorFieldApplicable(program)),
    E.right
  );

/** Whether the provided question id is required for the given Program */
const isQuestionRequired = (program: Program) => (qid: string) =>
  program.qrequired[qid];

/** Whether the provided question id is internal for the given Program */
const isQuestionInternal = (program: Program) => (qid: string) =>
  !!program.internal[qid];

/**
 * Guess whether each index of a given field was previously applicable
 *
 * If a field is not required, then we don't know whether a user has
 * encountered it or simply neglected to answer, and so we must simply
 * assume it was applicable.
 *
 * Otherwise, if a required field has an empty value, we assume that the
 * user must not have been presented with it, since it would otherwise have
 * a value.
 */
const inferPriorFieldApplicable = (program: Program) => (
  qid: string,
  values: unknown[]
) =>
  A.array.map(
    values,
    value => +(!isQuestionRequired(program)(qid) || value !== '')
  );

/** Current field state, as of this moment, with the current Program */
const currentFieldState = (quote: ServerSideQuote) =>
  pipe(
    quote.getFieldState(),
    E.fromNullable(Error('Current field state is not available'))
  );

/** `R.isEmpty` without loss of type information when used as a refinement  */
const isEmptyRecord: <A>(r: Record<string, A>) => boolean = R.isEmpty;

/**
 * Kick document back to the earliest field that is now applicable that was
 * not previously
 */
export const kickBackToNewlyApplicable = (program: Program) => (
  quote: ServerSideQuote
): IOE.IOEither<Error, ServerSideQuote> => () =>
  pipe(
    // Determine which fields are now applicable that were not previously,
    // failing if one of the arguments is not available
    pipe(
      E.of<Error, typeof fieldsNowApplicable>(fieldsNowApplicable),
      E.ap(lastPersistedFieldState(program)(quote)),
      E.ap(currentFieldState(quote))
    ),

    // Determine kickback step, if any, not to exceed the current step
    E.map(
      flow(
        // We must only consider required questions, otherwise the
        // invalidation of the question would force it to be required.  This
        // also allows us to exclude questions from kickback consideration.
        R.filterWithIndex(isQuestionRequired(program)),

        // Kicking back based on internal fields causes problems, because
        // the question state differs based on what type of user is viewing
        // the document.  It's also affected by systems using the session
        // key, which could trigger kickbacks while a non-internal user is
        // actively modifying/viewing the document, and offer no way for the
        // user to correct it.
        R.filterWithIndex(not(isQuestionInternal(program))),

        R.mapWithIndex(fieldStep(program)),
        R.filter(betweenPermittedKickbackRange(quote)),

        // At this point we know whether there are any fields that will
        // trigger a kickback; let's reify this fact as an `Option` so that
        // we later know whether to overwrite any step values
        O.fromPredicate(not(isEmptyRecord)),
        O.map(
          flow(
            invalidateFields(quote),
            R.foldMap(meetBeforeCurrentStep(quote))(stepIdFromPair)
          )
        )
      )
    ),

    // Impose step as upper bound on quote, being sure to honor current
    // values if they happen to be less than the kickback step.  The
    // `Option` here will ensure that we take no action if no kickback
    // fields were found.
    E.map(
      O.fold(constant(quote), kickback_step_id =>
        quote
          .setCurrentStepId(
            Math.min(quote.getCurrentStepId(), kickback_step_id)
          )
          .setTopVisitedStepId(
            Math.min(quote.getTopVisitedStepId(), kickback_step_id)
          )
          .setTopSavedStepId(
            Math.min(quote.getTopSavedStepId(), kickback_step_id)
          )
      )
    )
  );

/**
 * Whether the given step id is within the range permissable for kickback
 *
 * A kickback will not occur if the feature flag is not set on the
 * document.  The will be removed for release.
 *
 * If a document it locked, it'll be kicked back no lower than the lock step
 * if one is set; if there is no lock step but the document is locked, then
 * it will not be kicked back at all.
 *
 * The document will never be kicked back prior to the current step.
 */
const betweenPermittedKickbackRange = (quote: ServerSideQuote) =>
  between(ordPairStepId)(
    pairFromStepId(
      quote.isLocked() || !featureKickbackEnabled(quote)
        ? quote.getExplicitLockStep() || kickbackStepConsideration(quote)
        : -Infinity
    ),

    pairFromStepId(
      featureKickbackEnabled(quote)
        ? kickbackStepConsideration(quote)
        : -Infinity
    )
  );

/**
 * If only one of the provided values is an array, convert the other into an
 * array by broadcasting to an array of the same length as the other
 */
const normalizeIndexes = <T>(a: T | T[], b: T | T[]) =>
  isArray(a) && !isArray(b)
    ? [a, a.map(_ => b)]
    : !isArray(a) && isArray(b)
    ? [b.map(_ => a), b]
    : [a, b];

/**
 * Whether a given field has indexes that are applicable when they were not
 * previously
 *
 * The returned value, if `some`, has all indexes cleared except for those
 * indexes that have become applicable.
 */
const fieldNewlyApplicable = ([x, y]: any[]) =>
  pipe(
    isArray(x) ? x.map((v, i: number) => +(v && v !== y[i])) : +(x && x !== y),
    O.fromPredicate(x => (isArray(x) ? x.some(v => !!v) : !!x))
  );

/** List of fields that are now applicable but were not previously */
export const fieldsNowApplicable = (last: FieldState) => (cur: FieldState) =>
  pipe(
    cur,
    R.filterMapWithIndex((key, state) =>
      fieldNewlyApplicable(normalizeIndexes(state, last[key] || 0))
    )
  );

/** Step field is assigned to within the given program, otherwise Infinity **/
const fieldStep = (program: Program) => (
  field: string,
  state: State
): StateStepPair => [state, program.qstep[field] ?? Infinity];

/** Meet (minimum) of the given array of values, defaulting to current step */
const meetBeforeCurrentStep = (quote: ServerSideQuote) =>
  M.getMeetMonoid({
    ...B.boundedNumber,
    top: kickbackStepConsideration(quote),
  });

type State = RecordValue<FieldState>;
type StepId = number;
type StateStepPair = [State, StepId];

const pairFromStepId = (id: StepId): StateStepPair => [[], id];
const stateFromPair = (pair: StateStepPair) => pair[0];
const stepIdFromPair = (pair: StateStepPair) => pair[1];
const ordPairStepId = contramap(stepIdFromPair)(ordNumber);

/**
 * Invalidate all newly applicable fields, by index
 *
 * All indexes that are set for a given field will be set to the invalid
 * value `invalidValue`, which will cause a validation failure and force the
 * user to change its value.
 */
const invalidateFields = (quote: ServerSideQuote) => (
  fields: Record<string, StateStepPair>
) => {
  pipe(
    fields,
    R.map(stateFromPair),
    R.mapWithIndex(broadcastScalar(quote)),
    R.map(invalidDelta),
    quote.setData.bind(quote)
  );

  return fields;
};

type InvalidDelta = undefined | string | InvalidDelta[];

/** Generate a value for an invalid delta from the given `State` */
const invalidDelta = (x: State): InvalidDelta =>
  isArray(x) ? x.map(invalidDelta) : [undefined, invalidate('')][x];

/** Broadcast a scalar value onto a vector */
const broadcastScalar = (quote: ServerSideQuote) => (field: string, x: State) =>
  isArray(x) ? x : quote.getDataByName(field).map(_ => x);

/**
 * "Clean" quote, getting it into a stable state
 *
 * Quote cleaning will ensure that all group fields share at least the
 * same number of indexes as its leader, and that meta fields are
 * initialized.  This is useful when questions or meta fields are added.
 */
export const cleanDocument = (program: Program) => (quote: ServerSideQuote) =>
  pipe(
    quote,

    // Consider it an error to attempt cleaning a quote with the incorrect
    // program, which would surely corrupt it [even further]
    TE.fromPredicate(
      documentUsesProgram(program),
      constant(Error('Quote/program mismatch'))
    ),

    // Fix groups
    TE.chainFirst(
      flow(
        flow(fixGroup(program), O.of),
        // TODO: get rid of this extra expensive classify, considering that
        // a previous part of the system probably already did it!
        O.ap(O.of(quote.classify())),
        O.map(f =>
          A.array.traverse(IO.io)(
            Object.keys(program.groupIndexField || {}),
            f
          )()
        ),
        TE.fromOption(constant(Error('fixGroup failure')))
      )
    ),

    // Fix metadata
    TE.chainFirst(flow(fixMeta(program), TE.fromIO)),

    // Kick user back to newly applicable fields
    TE.chainIOEitherKW(kickBackToNewlyApplicable(program))
  );

/**
 * Correct group fields to be at least the length of the leader
 *
 * If a group is part of a link, then its leader may be part of another
 * group, and the length of the fields of all linked groups will match
 * be at least the length of the leader.
 *
 * Unlike previous implementations, this _does not_ truncate fields,
 * since that risks data loss.  Instead, field length should be
 * validated on save.
 */
const fixGroup = (program: Program) => (quote: ServerSideQuote) => (
  class_data: ClassData
) => (group_id: string) => () => {
  const length = groupLength(program)(quote)(group_id);

  // if we cannot accurately determine the length then it's too
  // dangerous to proceed and risk screwing up the data; abort
  // processing this group (this should never happen unless a program
  // is either not properly compiled or is out of date)
  if (isNaN(length)) {
    return;
  }

  const update: Record<string, string[]> = {};
  const group_fields = program.groupExclusiveFields[group_id];

  group_fields.forEach(field => {
    const flen = (quote.getDataByName(field) || []).length;

    // generated questions with no types should never be part of
    // the bucket
    if (!program.hasKnownType(field)) {
      return;
    }

    if (flen >= length) {
      return;
    }

    const data = [];
    const field_default = program.defaults[field] || '';

    for (let i = flen; i < length; i++) {
      data[i] =
        program.clearNaFields && program.hasNaField(field, class_data, i)
          ? program.naFieldValue
          : field_default;
    }

    update[field] = data;
  });

  quote.setData(update);
};

/**
 * Determine length of group GROUP_ID
 *
 * The length of a group is the length of its leader, which may be part
 * of another group (if the group is linked).
 */
const groupLength = (program: Program) => (quote: ServerSideQuote) => (
  group_id: string
) => {
  const index_field = program.groupIndexField[group_id];

  // we don't want to give the wrong answer, so just abort
  if (!index_field) {
    return NaN;
  }

  const data = quote.getDataByName(index_field);

  return Array.isArray(data) ? data.length : NaN;
};

/**
 * Initialize missing metadata
 *
 * This is similar to bucket initialization, except there are no leaders
 * or default values---just empty arrays.  That may change in the future.
 */
const fixMeta = (program: Program) => (quote: ServerSideQuote) => () => {
  const {fields = {}} = program.meta;
  const metabucket = quote.getMetabucket();
  const metadata = metabucket.getData();

  Object.keys(fields).forEach(field_name => {
    if (Array.isArray(metadata[field_name])) {
      return;
    }

    metabucket.setValues({[field_name]: []});
  });
};
