import { IVars } from "./vars";
import {
  Tip,
  IQueryResponse,
  IAttrResult,
  IDimResult,
  IQueryResult,
  Batch,
  ResponseHeaders,
  IHeaderValue,
} from "./models/types";
import { EnoFactory } from "./EnoFactory";
import { Observable, of, forkJoin } from "rxjs";
import { map, switchMap, tap, timeout } from "rxjs/operators";
import { send } from "./send";
import { IEnSrvOptions } from "./IEnSrvOptions";
import { checkBatchForError } from "./error";
import { getLangs, nowVar } from "./locale";
import { get, has, set } from "lodash";

export interface IQueryExtraInfo {
  label: string;
  formula: string;
}

export interface IDimensionOption extends IQueryExtraInfo {
  sortby?: string[];
  sortdir?: ("asc" | "desc")[];
  offset?: number;
  limit?: number;
}

export interface IQueryOption {
  branch?: Tip;
  lang?: string | string[];
  // watch?: boolean;
  vars?: IVars;
  extraFilters?: IQueryExtraInfo[];
  extraAttributes?: IQueryExtraInfo[];
  dimensionOptions?: IDimensionOption[];
  includeFallbackLang?: boolean;
  responseHeadersToInclude?: ResponseHeaders;
  lastPersist?: string;
}

// Executes a one-dimensional query
export function execute1d<T>(
  queryTip: Tip,
  enSrvOptions: IEnSrvOptions,
  options: IQueryOption = {
    branch: "branch/master",
    lang: "en-us",
    // watch: true,
    vars: {},
    extraFilters: [],
    extraAttributes: [],
    dimensionOptions: [{ label: "Tip", formula: "TIP()" }],
    includeFallbackLang: true
  },
  timeoutMs: number = 10000
): Observable<T[]> {
  return execute(queryTip, enSrvOptions, options, timeoutMs).pipe(
    map((response: IQueryResponse) => {
      const keys = response.dimensions[0].values;
      return response.results.map((result, i) => {
        return <T>(<any>result[keys[i]]);
      });
    })
  );
}

export function execute1dWithResponseHeaders<T>(
  queryTip: Tip,
  enSrvOptions: IEnSrvOptions,
  options: IQueryOption = {
    branch: "branch/master",
    lang: "en-us",
    vars: {},
    extraFilters: [],
    extraAttributes: [],
    dimensionOptions: [{ label: "Tip", formula: "TIP()" }],
    includeFallbackLang: true,
    responseHeadersToInclude: []
  },
  timeoutMs: number = 10000
): Observable<T[] | { results: T[]; responseHeaders: IHeaderValue[] }> {
  return execute(queryTip, enSrvOptions, options, timeoutMs).pipe(
    map((response: IQueryResponse) => {
      const keys = response.dimensions[0].values;
      const results = response.results.map((result, i) => {
        return <T>(<any>result[keys[i]]);
      });
      return response.responseHeaders
        ? { results, responseHeaders: response.responseHeaders }
        : results;
    })
  );
}

// Executes a query on Ensrv
export function execute(
  queryTip: Tip,
  enSrvOptions: IEnSrvOptions,
  options: IQueryOption = {
    branch: "branch/master",
    lang: "en-us",
    // watch: true,
    vars: {},
    extraFilters: [],
    dimensionOptions: [{ label: "Tip", formula: "TIP()" }],
    includeFallbackLang: true,
    responseHeadersToInclude: []
  },
  timeoutMs: number = 10000
): Observable<IQueryResponse> {
  const setNow = (now: string) => set(options, ["vars", "---NOW---"], [now]);
  const timezone$ = has(options, ["vars", "---NOW---"])
    ? of(null)
    : nowVar(enSrvOptions).pipe(tap(setNow));
  const langs$ = getLangs(
    enSrvOptions,
    get(options, "lang"),
    get(options, "includeFallbackLang", true)
  );
  const send$ = (langs: string[]) => {
    const { queryTimeoutMs, observableTimeoutMs } =
      calcQueryTimeouts(timeoutMs);
    const enoFactory = new EnoFactory("op/query", "security/policy/op");
    enoFactory.setField("op/query/tip", [queryTip]);
    enoFactory.setField("op/query/branch", [get(options, 'branch', 'branch/master')]);
    enoFactory.setField("op/query/lang", langs);
    enoFactory.setField("op/query/timeout", [queryTimeoutMs.toString()]);
    enoFactory.setField("op/query/query", [
      JSON.stringify({
        attributes: options.extraAttributes,
        filters: options.extraFilters,
        vars: options.vars || {},
        dimensions: options.dimensionOptions || [],
        lastPersist: options.lastPersist,
      }),
    ]);

    return send([enoFactory.makeEno()], enSrvOptions, options).pipe(
      tap(checkBatchForError),
      map((batch) =>
        parseResponse(
          batch,
          options?.responseHeadersToInclude as IHeaderValue[]
        )
      ),
      timeout(observableTimeoutMs)
    );
  };

  // return send$();
  return forkJoin({ tz: timezone$, langs: langs$ }).pipe(
    switchMap(({ langs }) => send$(langs))
  );
}

/**
 * There are two timeouts:
 *
 *   (1) the timeout for EnSrv to abort early on a query
 *       This will be minimum of 1s up to timeoutMs - 2s
 *
 *   (2) the timeout for our Observable to abort early
 *       This will be minimum of 1.5s up to timeoutMs
 *
 * So our observable should always timeout after EnSrv does
 */

export function calcQueryTimeouts(timeoutMs: number): {
  queryTimeoutMs: number;
  observableTimeoutMs: number;
} {
  const timeoutBufferMs: number = 2000;
  const minimumQueryTimeoutMs: number = 1000;
  const maximumQueryTimeoutMs: number = 28000;
  const minimumObservableTimeoutMs: number = 1500;
  const queryTimeoutMs = Math.min(
    maximumQueryTimeoutMs,
    Math.max(minimumQueryTimeoutMs, timeoutMs - timeoutBufferMs)
  );
  const observableTimeoutMs = Math.max(minimumObservableTimeoutMs, timeoutMs);

  return { queryTimeoutMs, observableTimeoutMs };
}

// Convert the query response batch to a query response
function parseResponse(sendBatch: Batch, responseHeaders?: IHeaderValue[]): IQueryResponse {
  let packedResults = undefined;
  const queryResponse: IQueryResponse = {
    attributes: [],
    dimensions: [],
    execTime: undefined,
    results: [],
  };
  let runtimeDims = [];
  let runtimeAttrs = [];

  sendBatch.forEach((eno) => {
    switch (eno.getType()) {
      case "response/query":
        queryResponse.execTime = eno.getFieldNumberValue(
          "response/query/exec-time"
        );
        runtimeAttrs = eno
          .getFieldValues("response/query/runtime-attributes")
          .map((attrJson) => JSON.parse(attrJson));
        runtimeDims = eno
          .getFieldValues("response/query/runtime-dimensions")
          .map((dim) => JSON.parse(dim))
          .map((dim) => ({ label: dim.label, values: dim.value }));
        packedResults = eno.getFieldValues("response/query/result");
        break;
      case "response/query/dimension":
        queryResponse.dimensions.push({
          tip: eno.tip,
          label: eno.getFieldStringValue("response/query/dimension/label"),
          values: eno.getFieldValues("response/query/dimension/value"),
        });
        break;
      case "query/attribute":
        queryResponse.attributes.push({
          tip: eno.tip,
          label: eno.getFieldStringValue("query/attribute/label"),
          formula: eno.getFieldStringValue("query/attribute/formula"),
        });
        break;
    }
  });

  if (packedResults === undefined) {
    throw new Error("No query response");
  }

  runtimeAttrs.forEach((runtimeAttr) =>
    queryResponse.attributes.push(runtimeAttr)
  );
  runtimeDims.forEach((runtimeDim) =>
    queryResponse.dimensions.push(runtimeDim)
  );

  if (queryResponse.dimensions.length === 0) {
    throw new Error("No dimension in response");
  }

  queryResponse.results = unpackQueryResults(
    packedResults,
    queryResponse.dimensions,
    queryResponse.attributes
  );
  
  return {
    ...queryResponse,
    ...(responseHeaders ? { responseHeaders } : {}),
  };
}

function unpackQueryResults(
  flatResult: string[],
  dims: IDimResult[],
  attrs: IAttrResult[],
  result: IQueryResult[] = [],
  depth: number = 0
): IQueryResult[] {
  if (depth === dims.length - 1) {
    let i = 0;

    dims[dims.length - 1].values.forEach((lastDimValue) => {
      const leafResult: IQueryResult = {};
      leafResult[lastDimValue] = {};

      if (flatResult[i] === "{#}") {
        i += attrs.length;

        return;
      }

      for (const attr of attrs) {
        leafResult[lastDimValue][attr.label] = flatResult[i++];
      }

      result.push(leafResult);
    });

    return result;
  }

  dims[depth].values.forEach((dimValue, i) => {
    const subResult: IQueryResult[] = [];
    const subResultSize = flatResult.length / dims[depth].values.length;
    const subResultStart = i * subResultSize;
    const subResultEnd = subResultStart + subResultSize;

    result[i] = {};
    result[i][dimValue] = subResult;

    unpackQueryResults(
      flatResult.slice(subResultStart, subResultEnd),
      dims,
      attrs,
      subResult,
      depth + 1
    );
  });

  return result;
}
