import { Observable, of } from "rxjs";
import { switchMap, delay, timeout, map, catchError, finalize } from "rxjs/operators";
import { Batch, Tip } from "./models/types";
import { EnoFactory } from "./EnoFactory";
import { IVars } from "./vars";
import { send } from "./send";
import { IEnSrvOptions } from "./IEnSrvOptions";
import { checkBatchForError } from "./error";

const DefaultRetryDelayMs = 5000;
const DefaultRetryAttempts = 20;
const DefaultTimeoutMs = 60000;
const OpTimeoutBufferMs = 3000; // The millis subtracted from the timeout to leave enough time to check the status
const MinOpTimeoutMs = 1000; // The minimum timeout in millis for the op/process
const MaxOpTimeoutMs = 20000; // The maximum timeout in millis for the op/process
const SessionInitTimeoutMs = 10000;

export interface IProcessOptions {
  waitForFinish?: boolean;
  inputVars?: IVars;
  retryDelayMs?: number;
  retryAttempts?: number;
  timeoutMs?: number;
}

export interface IProcessResponse {
  operationTip: Tip;
  responseTip: Tip;
  isFinished: boolean;
  outputVars?: IVars;
}

// Starts a process on EnSrv
// If waitForFinish, then the observable will not return until the process is finished.
// Otherwise will return once the first response is received
export function startProcess(
  tip: Tip,
  enSrvOptions: IEnSrvOptions,
  processOptions: IProcessOptions = {}
): Observable<IProcessResponse> {
  const enoFactory = new EnoFactory("op/process", "security/policy/op");
  enoFactory.setField("op/process/process", [tip]);
  enoFactory.setField("op/process/inline-vars", [
    JSON.stringify(processOptions.inputVars || {}),
  ]);
  const processOp = enoFactory.makeEno();

  let attemptsRemaining = processOptions.retryAttempts || DefaultRetryAttempts;
  let checkResponse$: (
    processResponse: IProcessResponse
  ) => Observable<IProcessResponse>;

  checkResponse$ = (
    processResponse: IProcessResponse
  ): Observable<IProcessResponse> => {
    if (processResponse.isFinished || !processOptions.waitForFinish) {
      return of(processResponse);
    }
    if (attemptsRemaining-- > 0) {
      return getProcessStatus(processOp.tip, enSrvOptions).pipe(
        delay(processOptions.retryDelayMs || DefaultRetryDelayMs),
        switchMap(checkResponse$)
      );
    }
    throw new Error("Too many attempts waiting for process to finish");
  };

  const timeoutMs = processOptions.timeoutMs || DefaultTimeoutMs;
  const opProcessTimeoutMs = Math.min(MaxOpTimeoutMs, Math.max(MinOpTimeoutMs, timeoutMs-OpTimeoutBufferMs));

  // Check if we already have a session token
  const hasExistingSession = !!enSrvOptions.sessionToken;

  // Set maintainInitialSessionToken to true so that `send` populates the initialSessionToken
  const originalMaintainInitialSessionToken = enSrvOptions.maintainInitialSessionToken;
  enSrvOptions.maintainInitialSessionToken = true;

  // Create a function that returns the main process observable
  const createProcessObservable = () => send([processOp], enSrvOptions).pipe(
    // Timeout the op/process request slightly before the overall timeout to allow for checking the status
    timeout(opProcessTimeoutMs),

    // Convert the ENO batch to an IProcessResponse
    map((batch) => makeProcessResponseFromBatch(processOp.tip, batch)),

    // If we received any failure from starting the process or getting the process response out of the error
    // then proactively go fetch the status to double-check
    catchError(() => getProcessStatus(processOp.tip, enSrvOptions)),

    // Poll the status if its not finished and we need to wait for it to finish
    switchMap(checkResponse$),

    // Abort the whole thing if we timeout
    timeout(timeoutMs),

    // Revert maintainInitialSessionToken back to its original value after process completes
    finalize(() => {
      enSrvOptions.maintainInitialSessionToken = originalMaintainInitialSessionToken;
    })
  );

  // If we already have a session, skip session establishment and go directly to process
  if (hasExistingSession) {
    return createProcessObservable();
  }

  // Otherwise, establish the session by sending an empty batch first
  return send([], enSrvOptions).pipe(
    timeout(SessionInitTimeoutMs),

    catchError(() => {
      // Session initialization failed - ignore the error and proceed to process
      // execution. The process execution could still succeed. However, if timeout
      // error occurs, the op/process/status will fail since we could not establish
      // an initial session.
      return of(null);
    }),

    // Send the process operation to EnSrv. The enSrvOptions is now populated with the sessionToken
    switchMap(() => createProcessObservable())
  );
}

// Get the status of a process operation
export function getProcessStatus(
  processOpTip: Tip,
  enSrvOptions: IEnSrvOptions
): Observable<IProcessResponse> {
  // Create a copy of options and use initialSessionToken if available
  // 'op/process/status' requires the initial session the process was triggered with
  const statusOptions = { ...enSrvOptions };
  if (statusOptions.initialSessionToken) {
    statusOptions.sessionToken = statusOptions.initialSessionToken;
  }

  const enoFactory = new EnoFactory("op/process/status", "security/policy/op");
  enoFactory.setField("op/process/status:op-tip", [processOpTip]);
  const statusOpEno = enoFactory.makeEno();
  return send([statusOpEno], statusOptions).pipe(
    map((batch) => makeProcessResponseFromBatch(processOpTip, batch))
  );
}

// Handle the process response batch, and retry if necessary
function makeProcessResponseFromBatch(
  processOpTip: Tip,
  batch: Batch
): IProcessResponse {
  checkBatchForError(batch);
  for (let i = 0; i < batch.length; i++) {
    const eno = batch[i];
    if (
      eno.getType() === "response/process" &&
      eno.getFieldStringValue("response/process/op-tip") === processOpTip
    ) {
      return {
        operationTip: processOpTip,
        responseTip: eno.tip,
        outputVars: eno.getFieldJsonValue("response/process/inline-vars"),
        isFinished: eno.getFieldBooleanValue("response/process/finished"),
      };
    }
  }
  // Something went wrong. There was no response/process in the response batch
  throw new Error("error/message/server/internal");
}
