/**
 * The contents of this file are subject to the license and copyright
 * detailed in the LICENSE and NOTICE files at the root of the source
 * tree and available online at
 *
 * http://www.dspace.org/license/
 */

import { AsyncSubject, from as observableFrom, Observable, of as observableOf, shareReplay } from 'rxjs';
import { map, mergeMap, skipWhile, switchMap, take, tap, toArray } from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { RequestParam } from '../../cache/models/request-param.model';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { URLCombiner } from '../../url-combiner/url-combiner';
import { RemoteData } from '../remote-data';
import { GetRequest } from '../request.models';
import { RequestService } from '../request.service';
import { CacheableObject } from '../../cache/cacheable-object.model';
import { FindListOptions } from '../find-list-options.model';
import { PaginatedList } from '../paginated-list.model';
import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALDataService } from './hal-data-service.interface';
import { getFirstCompletedRemoteData } from '../../shared/operators';

export const EMBED_SEPARATOR = '%2F';
/**
 * Common functionality for data services.
 * Specific functionality that not all services would need
 * is implemented in "DataService feature" classes (e.g. {@link CreateData}
 *
 * All DataService (or DataService feature) classes must
 *   - extend this class (or {@link IdentifiableDataService})
 *   - implement any DataService features it requires in order to forward calls to it
 *
 * ```
 * export class SomeDataService extends BaseDataService<Something> implements CreateData<Something>, SearchData<Something> {
 *   private createData: CreateData<Something>;
 *   private searchData: SearchDataData<Something>;
 *
 *   create(...) {
 *     return this.createData.create(...);
 *   }
 *
 *   searchBy(...) {
 *     return this.searchData.searchBy(...);
 *   }
 * }
 * ```
 */
export class BaseDataService<T extends CacheableObject> implements HALDataService<T> {
  constructor(
    protected linkPath: string,
    protected requestService: RequestService,
    protected rdbService: RemoteDataBuildService,
    protected objectCache: ObjectCacheService,
    protected halService: HALEndpointService,
    protected responseMsToLive?: number,
  ) {
  }

  /**
   * Allows subclasses to reset the response cache time.
   */

  /**
   * Get the endpoint for browsing
   * @param options The [[FindListOptions]] object
   * @param linkPath The link path for the object
   * @returns {Observable<string>}
   */
  getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
    return this.getEndpoint();
  }

  /**
   * Get the base endpoint for all requests
   */
  protected getEndpoint(): Observable<string> {
    return this.halService.getEndpoint(this.linkPath);
  }

  /**
   * Turn an options object into a query string and combine it with the given HREF
   *
   * @param href The HREF to which the query string should be appended
   * @param options The [[FindListOptions]] object
   * @param extraArgs Array with additional params to combine with query string
   * @return {Observable<string>}
   *    Return an observable that emits created HREF
   * @param linksToFollow   List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
   */
  public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: FollowLinkConfig<T>[]): string {
    let args = [...extraArgs];

    if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
      /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */
      args = this.addHrefArg(href, args, `page=${options.currentPage - 1}`);
    }
    if (hasValue(options.elementsPerPage)) {
      args = this.addHrefArg(href, args, `size=${options.elementsPerPage}`);
    }
    if (hasValue(options.sort)) {
      args = this.addHrefArg(href, args, `sort=${options.sort.field},${options.sort.direction}`);
    }
    if (hasValue(options.startsWith)) {
      args = this.addHrefArg(href, args, `startsWith=${options.startsWith}`);
    }
    if (hasValue(options.searchParams)) {
      options.searchParams.forEach((param: RequestParam) => {
        args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`);
      });
    }
    args = this.addEmbedParams(href, args, ...linksToFollow);
    if (isNotEmpty(args)) {
      return new URLCombiner(href, `?${args.join('&')}`).toString();
    } else {
      return href;
    }
  }

  /**
   * Turn an array of RequestParam into a query string and combine it with the given HREF
   *
   * @param href The HREF to which the query string should be appended
   * @param params Array with additional params to combine with query string
   * @param linksToFollow   List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
   *
   * @return {Observable<string>}
   * Return an observable that emits created HREF
   */
  buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig<T>[]): string {

    let  args = [];
    if (hasValue(params)) {
      params.forEach((param: RequestParam) => {
        args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`);
      });
    }

    args = this.addEmbedParams(href, args, ...linksToFollow);

    if (isNotEmpty(args)) {
      return new URLCombiner(href, `?${args.join('&')}`).toString();
    } else {
      return href;
    }
  }
  /**
   * Adds the embed options to the link for the request
   * @param href            The href the params are to be added to
   * @param args            params for the query string
   * @param linksToFollow   links we want to embed in query string if shouldEmbed is true
   */
  protected addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig<T>[]) {
    linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
      if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) {
        const embedString = 'embed=' + String(linkToFollow.name);
        // Add the embeds size if given in the FollowLinkConfig.FindListOptions
        if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) {
          args = this.addHrefArg(href, args,
            'embed.size=' + String(linkToFollow.name) + '=' + linkToFollow.findListOptions.elementsPerPage);
        }
        // Adds the nested embeds and their size if given
        if (isNotEmpty(linkToFollow.linksToFollow)) {
          args = this.addNestedEmbeds(embedString, href, args, ...linkToFollow.linksToFollow);
        } else {
          args = this.addHrefArg(href, args, embedString);
        }
      }
    });
    return args;
  }

  /**
   * Add a new argument to the list of arguments, only if it doesn't already exist in the given href,
   * or the current list of arguments
   *
   * @param href        The href the arguments are to be added to
   * @param currentArgs The current list of arguments
   * @param newArg      The new argument to add
   * @return            The next list of arguments, with newArg included if it wasn't already.
   *                    Note this function will not modify any of the input params.
   */
  protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] {
    if (href.includes(newArg) || currentArgs.includes(newArg)) {
      return [...currentArgs];
    } else {
      return [...currentArgs, newArg];
    }
  }

  /**
   * Add the nested followLinks to the embed param, separated by a /, and their sizes, recursively
   * @param embedString     embedString so far (recursive)
   * @param href            The href the params are to be added to
   * @param args            params for the query string
   * @param linksToFollow   links we want to embed in query string if shouldEmbed is true
   */
  protected addNestedEmbeds(embedString: string, href: string, args: string[], ...linksToFollow: FollowLinkConfig<T>[]): string[] {
    let nestEmbed = embedString;
    linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
      if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) {
        nestEmbed = nestEmbed + EMBED_SEPARATOR + String(linkToFollow.name);
        // Add the nested embeds size if given in the FollowLinkConfig.FindListOptions
        if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) {
          const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage;
          args = this.addHrefArg(href, args, nestedEmbedSize);
        }
        if (hasValue(linkToFollow.linksToFollow) && isNotEmpty(linkToFollow.linksToFollow)) {
          args = this.addNestedEmbeds(nestEmbed, href, args, ...linkToFollow.linksToFollow);
        } else {
          args = this.addHrefArg(href, args, nestEmbed);
        }
      }
    });
    return args;
  }

  /**
   * An operator that will call the given function if the incoming RemoteData is stale and
   * shouldReRequest is true
   *
   * @param shouldReRequest  Whether or not to call the re-request function if the RemoteData is stale
   * @param requestFn        The function to call if the RemoteData is stale and shouldReRequest is
   *                         true
   */
  protected reRequestStaleRemoteData<O>(shouldReRequest: boolean, requestFn: () => Observable<RemoteData<O>>) {
    return (source: Observable<RemoteData<O>>): Observable<RemoteData<O>> => {
      if (shouldReRequest === true) {
        return source.pipe(
          tap((remoteData: RemoteData<O>) => {
            if (hasValue(remoteData) && remoteData.isStale) {
              requestFn();
            }
          })
        );
      } else {
        return source;
      }
    };
  }

  /**
   * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of
   * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
   * @param href$                       The url of object we want to retrieve. Can be a string or
   *                                    an Observable<string>
   * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
   *                                    no valid cached version. Defaults to true
   * @param reRequestOnStale            Whether or not the request should automatically be re-
   *                                    requested after the response becomes stale
   * @param linksToFollow               List of {@link FollowLinkConfig} that indicate which
   *                                    {@link HALLink}s should be automatically resolved
   */
  findByHref(href$: string | Observable<string>, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<T>> {
    if (typeof href$ === 'string') {
      href$ = observableOf(href$);
    }

    const requestHref$ = href$.pipe(
      isNotEmptyOperator(),
      take(1),
      map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)),
      shareReplay(1),
    );

    const startTime: number = new Date().getTime();
    this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);

    return this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
      // This skip ensures that if a stale object is present in the cache when you do a
      // call it isn't immediately returned, but we wait until the remote data for the new request
      // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
      // cached completed object
      skipWhile((rd: RemoteData<T>) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)),
      this.reRequestStaleRemoteData(reRequestOnStale, () =>
        this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
    );
  }

  /**
   * Returns an Observable of a {@link RemoteData} of a {@link PaginatedList} of objects, based on an href,
   * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
   *
   * @param href$                       The url of list we want to retrieve. Can be a string or an Observable<string>
   * @param options
   * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version.
   * @param reRequestOnStale            Whether or not the request should automatically be re-requested after the response becomes stale
   * @param linksToFollow               List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
   */
  findListByHref(href$: string | Observable<string>, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
    if (typeof href$ === 'string') {
      href$ = observableOf(href$);
    }

    const requestHref$ = href$.pipe(
      isNotEmptyOperator(),
      take(1),
      map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)),
      shareReplay(1),
    );

    const startTime: number = new Date().getTime();
    this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);

    return this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
      // This skip ensures that if a stale object is present in the cache when you do a
      // call it isn't immediately returned, but we wait until the remote data for the new request
      // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
      // cached completed object
      skipWhile((rd: RemoteData<PaginatedList<T>>) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)),
      this.reRequestStaleRemoteData(reRequestOnStale, () =>
        this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
    );
  }

  /**
   * Create a GET request for the given href, and send it.
   *
   * @param href$                       The url of object we want to retrieve. Can be a string or
   *                                    an Observable<string>
   * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
   *                                    no valid cached version. Defaults to true
   */
  protected createAndSendGetRequest(href$: string | Observable<string>, useCachedVersionIfAvailable = true): void {
    if (isNotEmpty(href$)) {
      if (typeof href$ === 'string') {
        href$ = observableOf(href$);
      }

      href$.pipe(
        isNotEmptyOperator(),
        take(1)
      ).subscribe((href: string) => {
        const requestId = this.requestService.generateRequestId();
        const request = new GetRequest(requestId, href);
        if (hasValue(this.responseMsToLive)) {
          request.responseMsToLive = this.responseMsToLive;
        }
        this.requestService.send(request, useCachedVersionIfAvailable);
      });
    }
  }

  /**
   * Checks for the provided href whether a response is already cached
   * @param href$                       The url for which to check whether there is a cached response.
   *                                    Can be a string or an Observable<string>
   */
  hasCachedResponse(href$: string | Observable<string>): Observable<boolean> {
    if (isNotEmpty(href$)) {
      if (typeof href$ === 'string') {
        href$ = observableOf(href$);
      }
      return href$.pipe(
        isNotEmptyOperator(),
        take(1),
        map((href: string) => {
          const requestId = this.requestService.generateRequestId();
          const request = new GetRequest(requestId, href);
          return !this.requestService.shouldDispatchRequest(request, true);
        }),
      );
    }
    throw new Error(`Can't check whether there is a cached response for an empty href$`);
  }

  /**
   * Checks for the provided href whether an ERROR response is currently cached
   * @param href$                       The url for which to check whether there is a cached ERROR response.
   *                                    Can be a string or an Observable<string>
   */
  hasCachedErrorResponse(href$: string | Observable<string>): Observable<boolean> {
    return this.hasCachedResponse(href$).pipe(
      switchMap((hasCachedResponse) => {
        if (hasCachedResponse) {
           return this.rdbService.buildSingle(href$).pipe(
             getFirstCompletedRemoteData(),
             map((rd => rd.hasFailed))
           );
        }
        return observableOf(false);
      })
    );
  }

  /**
   * Return the links to traverse from the root of the api to the
   * endpoint this DataService represents
   *
   * e.g. if the api root links to 'foo', and the endpoint at 'foo'
   * links to 'bar' the linkPath for the BarDataService would be
   * 'foo/bar'
   */
  getLinkPath(): string {
    return this.linkPath;
  }

  /**
   *  Shorthand method to add a dependency to a cached object
   *  ```
   *  const out$ = this.findByHref(...); // or another method that sends a request
   *  this.addDependency(out$, dependsOnHref);
   *  ```
   *  When {@link dependsOnHref$} is invalidated, {@link object$} will be invalidated as well.
   *
   *
   * @param object$         the cached object
   * @param dependsOnHref$  the href of the object it should depend on
   */
  protected addDependency(object$: Observable<RemoteData<T | PaginatedList<T>>>, dependsOnHref$: string | Observable<string>) {
    this.objectCache.addDependency(
      object$.pipe(
        getFirstCompletedRemoteData(),
        switchMap((rd: RemoteData<T>) => {
          if (rd.hasSucceeded) {
            return [rd.payload._links.self.href];
          } else {
            // undefined href will be skipped in objectCache.addDependency
            return [undefined];
          }
        }),
      ),
      dependsOnHref$
    );
  }

  /**
   * Invalidate an existing DSpaceObject by marking all requests it is included in as stale
   * @param   href The self link of the object to be invalidated
   * @return  An Observable that will emit `true` once all requests are stale
   */
  invalidateByHref(href: string): Observable<boolean> {
    const done$ = new AsyncSubject<boolean>();

    this.objectCache.getByHref(href).pipe(
      take(1),
      switchMap((oce: ObjectCacheEntry) => {
        return observableFrom([
          ...oce.requestUUIDs,
          ...oce.dependentRequestUUIDs
        ]).pipe(
          mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)),
          toArray(),
        );
      }),
    ).subscribe(() => {
      this.objectCache.removeDependents(href);
      done$.next(true);
      done$.complete();
    });

    return done$;
  }
}
