/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {HttpHeaders} from '@angular/common/http';
import {BehaviorSubject, from, Observable, Observer, of} from 'rxjs';
import {concatMap, first} from 'rxjs/operators';

import {delayResponse} from './delay-response';
import {getStatusText, isSuccess, STATUS} from './http-status-codes';
import {
  InMemoryBackendConfig,
  InMemoryBackendConfigArgs,
  InMemoryDbService,
  ParsedRequestUrl,
  parseUri,
  PassThruBackend,
  removeTrailingSlash,
  RequestCore,
  RequestInfo,
  RequestInfoUtilities,
  ResponseOptions,
  UriInfo,
} from './interfaces';

/**
 * Base class for in-memory web api back-ends
 * Simulate the behavior of a RESTy web api
 * backed by the simple in-memory data store provided by the injected `InMemoryDbService` service.
 * Conforms mostly to behavior described here:
 * http://www.restapitutorial.com/lessons/httpmethods.html
 */
export abstract class BackendService {
  protected config: InMemoryBackendConfigArgs = new InMemoryBackendConfig();
  protected db: {[key: string]: any} = {};
  protected dbReadySubject: BehaviorSubject<boolean> | undefined;
  private passThruBackend: PassThruBackend | undefined;
  protected requestInfoUtils = this.getRequestInfoUtils();

  constructor(
    protected inMemDbService: InMemoryDbService,
    config: InMemoryBackendConfigArgs = {},
  ) {
    const loc = this.getLocation('/');
    this.config.host = loc.host; // default to app web server host
    this.config.rootPath = loc.path; // default to path when app is served (e.g.'/')
    Object.assign(this.config, config);
  }

  protected get dbReady(): Observable<boolean> {
    if (!this.dbReadySubject) {
      // first time the service is called.
      this.dbReadySubject = new BehaviorSubject<boolean>(false);
      this.resetDb();
    }
    return this.dbReadySubject.asObservable().pipe(first((r: boolean) => r));
  }

  /**
   * Process Request and return an Observable of Http Response object
   * in the manner of a RESTy web api.
   *
   * Expect URI pattern in the form :base/:collectionName/:id?
   * Examples:
   *   // for store with a 'customers' collection
   *   GET api/customers          // all customers
   *   GET api/customers/42       // the character with id=42
   *   GET api/customers?name=^j  // 'j' is a regex; returns customers whose name starts with 'j' or
   * 'J' GET api/customers.json/42  // ignores the ".json"
   *
   * Also accepts direct commands to the service in which the last segment of the apiBase is the
   * word "commands" Examples: POST commands/resetDb, GET/POST commands/config - get or (re)set the
   * config
   *
   *   HTTP overrides:
   *     If the injected inMemDbService defines an HTTP method (lowercase)
   *     The request is forwarded to that method as in
   *     `inMemDbService.get(requestInfo)`
   *     which must return either an Observable of the response type
   *     for this http library or null|undefined (which means "keep processing").
   */
  protected handleRequest(req: RequestCore): Observable<any> {
    //  handle the request when there is an in-memory database
    return this.dbReady.pipe(concatMap(() => this.handleRequest_(req)));
  }

  protected handleRequest_(req: RequestCore): Observable<any> {
    const url = req.urlWithParams ? req.urlWithParams : req.url;

    // Try override parser
    // If no override parser or it returns nothing, use default parser
    const parser = this.bind('parseRequestUrl');
    const parsed: ParsedRequestUrl =
      (parser && parser(url, this.requestInfoUtils)) || this.parseRequestUrl(url);

    const collectionName = parsed.collectionName;
    const collection = this.db[collectionName];

    const reqInfo: RequestInfo = {
      req: req,
      apiBase: parsed.apiBase,
      collection: collection,
      collectionName: collectionName,
      headers: this.createHeaders({'Content-Type': 'application/json'}),
      id: this.parseId(collection, collectionName, parsed.id),
      method: this.getRequestMethod(req),
      query: parsed.query,
      resourceUrl: parsed.resourceUrl,
      url: url,
      utils: this.requestInfoUtils,
    };

    let resOptions: ResponseOptions;

    if (/commands\/?$/i.test(reqInfo.apiBase)) {
      return this.commands(reqInfo);
    }

    const methodInterceptor = this.bind(reqInfo.method);
    if (methodInterceptor) {
      // InMemoryDbService intercepts this HTTP method.
      // if interceptor produced a response, return it.
      // else InMemoryDbService chose not to intercept; continue processing.
      const interceptorResponse = methodInterceptor(reqInfo);
      if (interceptorResponse) {
        return interceptorResponse;
      }
    }

    if (this.db[collectionName]) {
      // request is for a known collection of the InMemoryDbService
      return this.createResponse$(() => this.collectionHandler(reqInfo));
    }

    if (this.config.passThruUnknownUrl) {
      // unknown collection; pass request thru to a "real" backend.
      return this.getPassThruBackend().handle(req);
    }

    // 404 - can't handle this request
    resOptions = this.createErrorResponseOptions(
      url,
      STATUS.NOT_FOUND,
      `Collection '${collectionName}' not found`,
    );
    return this.createResponse$(() => resOptions);
  }

  /**
   * Add configured delay to response observable unless delay === 0
   */
  protected addDelay(response: Observable<any>): Observable<any> {
    const d = this.config.delay;
    return d === 0 ? response : delayResponse(response, d || 500);
  }

  /**
   * Apply query/search parameters as a filter over the collection
   * This impl only supports RegExp queries on string properties of the collection
   * ANDs the conditions together
   */
  protected applyQuery(collection: any[], query: Map<string, string[]>): any[] {
    // extract filtering conditions - {propertyName, RegExps) - from query/search parameters
    const conditions: {name: string; rx: RegExp}[] = [];
    const caseSensitive = this.config.caseSensitiveSearch ? undefined : 'i';
    query.forEach((value: string[], name: string) => {
      value.forEach((v) => conditions.push({name, rx: new RegExp(decodeURI(v), caseSensitive)}));
    });

    const len = conditions.length;
    if (!len) {
      return collection;
    }

    // AND the RegExp conditions
    return collection.filter((row) => {
      let ok = true;
      let i = len;
      while (ok && i) {
        i -= 1;
        const cond = conditions[i];
        ok = cond.rx.test(row[cond.name]);
      }
      return ok;
    });
  }

  /**
   * Get a method from the `InMemoryDbService` (if it exists), bound to that service
   */
  protected bind<T extends Function>(methodName: string) {
    const fn = (this.inMemDbService as any)[methodName];
    return fn ? (fn.bind(this.inMemDbService) as T) : undefined;
  }

  protected bodify(data: any) {
    return this.config.dataEncapsulation ? {data} : data;
  }

  protected clone(data: any) {
    return JSON.parse(JSON.stringify(data));
  }

  protected collectionHandler(reqInfo: RequestInfo): ResponseOptions {
    // const req = reqInfo.req;
    let resOptions: ResponseOptions;
    switch (reqInfo.method) {
      case 'get':
        resOptions = this.get(reqInfo);
        break;
      case 'post':
        resOptions = this.post(reqInfo);
        break;
      case 'put':
        resOptions = this.put(reqInfo);
        break;
      case 'delete':
        resOptions = this.delete(reqInfo);
        break;
      default:
        resOptions = this.createErrorResponseOptions(
          reqInfo.url,
          STATUS.METHOD_NOT_ALLOWED,
          'Method not allowed',
        );
        break;
    }

    // If `inMemDbService.responseInterceptor` exists, let it morph the response options
    const interceptor = this.bind('responseInterceptor');
    return interceptor ? interceptor(resOptions, reqInfo) : resOptions;
  }

  /**
   * Commands reconfigure the in-memory web api service or extract information from it.
   * Commands ignore the latency delay and respond ASAP.
   *
   * When the last segment of the `apiBase` path is "commands",
   * the `collectionName` is the command.
   *
   * Example URLs:
   *   commands/resetdb (POST) // Reset the "database" to its original state
   *   commands/config (GET)   // Return this service's config object
   *   commands/config (POST)  // Update the config (e.g. the delay)
   *
   * Usage:
   *   http.post('commands/resetdb', undefined);
   *   http.get('commands/config');
   *   http.post('commands/config', '{"delay":1000}');
   */
  protected commands(reqInfo: RequestInfo): Observable<any> {
    const command = reqInfo.collectionName.toLowerCase();
    const method = reqInfo.method;

    let resOptions: ResponseOptions = {url: reqInfo.url};

    switch (command) {
      case 'resetdb':
        resOptions.status = STATUS.NO_CONTENT;
        return this.resetDb(reqInfo).pipe(
          concatMap(() => this.createResponse$(() => resOptions, false /* no latency delay */)),
        );

      case 'config':
        if (method === 'get') {
          resOptions.status = STATUS.OK;
          resOptions.body = this.clone(this.config);

          // any other HTTP method is assumed to be a config update
        } else {
          const body = this.getJsonBody(reqInfo.req);
          Object.assign(this.config, body);
          this.passThruBackend = undefined; // re-create when needed

          resOptions.status = STATUS.NO_CONTENT;
        }
        break;

      default:
        resOptions = this.createErrorResponseOptions(
          reqInfo.url,
          STATUS.INTERNAL_SERVER_ERROR,
          `Unknown command "${command}"`,
        );
    }

    return this.createResponse$(() => resOptions, false /* no latency delay */);
  }

  protected createErrorResponseOptions(
    url: string,
    status: number,
    message: string,
  ): ResponseOptions {
    return {
      body: {error: `${message}`},
      url: url,
      headers: this.createHeaders({'Content-Type': 'application/json'}),
      status: status,
    };
  }

  /**
   * Create standard HTTP headers object from hash map of header strings
   * @param headers
   */
  protected abstract createHeaders(headers: {[index: string]: string}): HttpHeaders;

  /**
   * create the function that passes unhandled requests through to the "real" backend.
   */
  protected abstract createPassThruBackend(): PassThruBackend;

  /**
   * return a search map from a location query/search string
   */
  protected abstract createQueryMap(search: string): Map<string, string[]>;

  /**
   * Create a cold response Observable from a factory for ResponseOptions
   * @param resOptionsFactory - creates ResponseOptions when observable is subscribed
   * @param withDelay - if true (default), add simulated latency delay from configuration
   */
  protected createResponse$(
    resOptionsFactory: () => ResponseOptions,
    withDelay = true,
  ): Observable<any> {
    const resOptions$ = this.createResponseOptions$(resOptionsFactory);
    let resp$ = this.createResponse$fromResponseOptions$(resOptions$);
    return withDelay ? this.addDelay(resp$) : resp$;
  }

  /**
   * Create a Response observable from ResponseOptions observable.
   */
  protected abstract createResponse$fromResponseOptions$(
    resOptions$: Observable<ResponseOptions>,
  ): Observable<any>;

  /**
   * Create a cold Observable of ResponseOptions.
   * @param resOptionsFactory - creates ResponseOptions when observable is subscribed
   */
  protected createResponseOptions$(
    resOptionsFactory: () => ResponseOptions,
  ): Observable<ResponseOptions> {
    return new Observable<ResponseOptions>((responseObserver: Observer<ResponseOptions>) => {
      let resOptions: ResponseOptions;
      try {
        resOptions = resOptionsFactory();
      } catch (error) {
        const err = (error as Error).message || error;
        resOptions = this.createErrorResponseOptions('', STATUS.INTERNAL_SERVER_ERROR, `${err}`);
      }

      const status = resOptions.status;
      try {
        resOptions.statusText = status != null ? getStatusText(status) : undefined;
      } catch (e) {
        /* ignore failure */
      }
      if (status != null && isSuccess(status)) {
        responseObserver.next(resOptions);
        responseObserver.complete();
      } else {
        responseObserver.error(resOptions);
      }
      return () => {}; // unsubscribe function
    });
  }

  protected delete({collection, collectionName, headers, id, url}: RequestInfo): ResponseOptions {
    if (id == null) {
      return this.createErrorResponseOptions(
        url,
        STATUS.NOT_FOUND,
        `Missing "${collectionName}" id`,
      );
    }
    const exists = this.removeById(collection, id);
    return {
      headers: headers,
      status: exists || !this.config.delete404 ? STATUS.NO_CONTENT : STATUS.NOT_FOUND,
    };
  }

  /**
   * Find first instance of item in collection by `item.id`
   * @param collection
   * @param id
   */
  protected findById<T extends {id: any}>(collection: T[], id: any): T | undefined {
    return collection.find((item: T) => item.id === id);
  }

  /**
   * Generate the next available id for item in this collection
   * Use method from `inMemDbService` if it exists and returns a value,
   * else delegates to `genIdDefault`.
   * @param collection - collection of items with `id` key property
   */
  protected genId<T extends {id: any}>(collection: T[], collectionName: string): any {
    const genId = this.bind('genId');
    if (genId) {
      const id = genId(collection, collectionName);
      if (id != null) {
        return id;
      }
    }
    return this.genIdDefault(collection, collectionName);
  }

  /**
   * Default generator of the next available id for item in this collection
   * This default implementation works only for numeric ids.
   * @param collection - collection of items with `id` key property
   * @param collectionName - name of the collection
   */
  protected genIdDefault<T extends {id: any}>(collection: T[], collectionName: string): any {
    if (!this.isCollectionIdNumeric(collection, collectionName)) {
      throw new Error(
        `Collection '${collectionName}' id type is non-numeric or unknown. Can only generate numeric ids.`,
      );
    }

    let maxId = 0;
    collection.reduce((prev: any, item: any) => {
      maxId = Math.max(maxId, typeof item.id === 'number' ? item.id : maxId);
    }, undefined);
    return maxId + 1;
  }

  protected get({
    collection,
    collectionName,
    headers,
    id,
    query,
    url,
  }: RequestInfo): ResponseOptions {
    let data = collection;

    if (id != null && id !== '') {
      data = this.findById(collection, id);
    } else if (query) {
      data = this.applyQuery(collection, query);
    }

    if (!data) {
      return this.createErrorResponseOptions(
        url,
        STATUS.NOT_FOUND,
        `'${collectionName}' with id='${id}' not found`,
      );
    }
    return {body: this.bodify(this.clone(data)), headers: headers, status: STATUS.OK};
  }

  /** Get JSON body from the request object */
  protected abstract getJsonBody(req: any): any;

  /**
   * Get location info from a url, even on server where `document` is not defined
   */
  protected getLocation(url: string): UriInfo {
    if (!url.startsWith('http')) {
      // get the document iff running in browser
      const doc = typeof document === 'undefined' ? undefined : document;
      // add host info to url before parsing.  Use a fake host when not in browser.
      const base = doc ? doc.location.protocol + '//' + doc.location.host : 'http://fake';
      url = url.startsWith('/') ? base + url : base + '/' + url;
    }
    return parseUri(url);
  }

  /**
   * get or create the function that passes unhandled requests
   * through to the "real" backend.
   */
  protected getPassThruBackend(): PassThruBackend {
    return this.passThruBackend
      ? this.passThruBackend
      : (this.passThruBackend = this.createPassThruBackend());
  }

  /**
   * Get utility methods from this service instance.
   * Useful within an HTTP method override
   */
  protected getRequestInfoUtils(): RequestInfoUtilities {
    return {
      createResponse$: this.createResponse$.bind(this),
      findById: this.findById.bind(this),
      isCollectionIdNumeric: this.isCollectionIdNumeric.bind(this),
      getConfig: () => this.config,
      getDb: () => this.db,
      getJsonBody: this.getJsonBody.bind(this),
      getLocation: this.getLocation.bind(this),
      getPassThruBackend: this.getPassThruBackend.bind(this),
      parseRequestUrl: this.parseRequestUrl.bind(this),
    };
  }

  /**
   * return canonical HTTP method name (lowercase) from the request object
   * e.g. (req.method || 'get').toLowerCase();
   * @param req - request object from the http call
   *
   */
  protected abstract getRequestMethod(req: any): string;

  protected indexOf(collection: any[], id: number): number {
    return collection.findIndex((item: any) => item.id === id);
  }

  /** Parse the id as a number. Return original value if not a number. */
  protected parseId(collection: any[], collectionName: string, id: string): any {
    if (!this.isCollectionIdNumeric(collection, collectionName)) {
      // Can't confirm that `id` is a numeric type; don't parse as a number
      // or else `'42'` -> `42` and _get by id_ fails.
      return id;
    }
    const idNum = parseFloat(id);
    return isNaN(idNum) ? id : idNum;
  }

  /**
   * return true if can determine that the collection's `item.id` is a number
   * This implementation can't tell if the collection is empty so it assumes NO
   * */
  protected isCollectionIdNumeric<T extends {id: any}>(
    collection: T[],
    collectionName: string,
  ): boolean {
    // collectionName not used now but override might maintain collection type information
    // so that it could know the type of the `id` even when the collection is empty.
    return !!(collection && collection[0]) && typeof collection[0].id === 'number';
  }

  /**
   * Parses the request URL into a `ParsedRequestUrl` object.
   * Parsing depends upon certain values of `config`: `apiBase`, `host`, and `urlRoot`.
   *
   * Configuring the `apiBase` yields the most interesting changes to `parseRequestUrl` behavior:
   *   When apiBase=undefined and url='http://localhost/api/collection/42'
   *     {base: 'api/', collectionName: 'collection', id: '42', ...}
   *   When apiBase='some/api/root/' and url='http://localhost/some/api/root/collection'
   *     {base: 'some/api/root/', collectionName: 'collection', id: undefined, ...}
   *   When apiBase='/' and url='http://localhost/collection'
   *     {base: '/', collectionName: 'collection', id: undefined, ...}
   *
   * The actual api base segment values are ignored. Only the number of segments matters.
   * The following api base strings are considered identical: 'a/b' ~ 'some/api/' ~ `two/segments'
   *
   * To replace this default method, assign your alternative to your
   * InMemDbService['parseRequestUrl']
   */
  protected parseRequestUrl(url: string): ParsedRequestUrl {
    try {
      const loc = this.getLocation(url);
      let drop = (this.config.rootPath || '').length;
      let urlRoot = '';
      if (loc.host !== this.config.host) {
        // url for a server on a different host!
        // assume it's collection is actually here too.
        drop = 1; // the leading slash
        urlRoot = loc.protocol + '//' + loc.host + '/';
      }
      const path = loc.path.substring(drop);
      const pathSegments = path.split('/');
      let segmentIndex = 0;

      // apiBase: the front part of the path devoted to getting to the api route
      // Assumes first path segment if no config.apiBase
      // else ignores as many path segments as are in config.apiBase
      // Does NOT care what the api base chars actually are.
      let apiBase: string;
      if (this.config.apiBase == null) {
        apiBase = pathSegments[segmentIndex++];
      } else {
        apiBase = removeTrailingSlash(this.config.apiBase.trim());
        if (apiBase) {
          segmentIndex = apiBase.split('/').length;
        } else {
          segmentIndex = 0; // no api base at all; unwise but allowed.
        }
      }
      apiBase += '/';

      let collectionName = pathSegments[segmentIndex++];
      // ignore anything after a '.' (e.g.,the "json" in "customers.json")
      collectionName = collectionName && collectionName.split('.')[0];

      const id = pathSegments[segmentIndex++];
      const query = this.createQueryMap(loc.query);
      const resourceUrl = urlRoot + apiBase + collectionName + '/';
      return {apiBase, collectionName, id, query, resourceUrl};
    } catch (err) {
      const msg = `unable to parse url '${url}'; original error: ${(err as Error).message}`;
      throw new Error(msg);
    }
  }

  // Create entity
  // Can update an existing entity too if post409 is false.
  protected post({
    collection,
    collectionName,
    headers,
    id,
    req,
    resourceUrl,
    url,
  }: RequestInfo): ResponseOptions {
    const item = this.clone(this.getJsonBody(req));

    if (item.id == null) {
      try {
        item.id = id || this.genId(collection, collectionName);
      } catch (err) {
        const emsg: string = (err as Error).message || '';
        if (/id type is non-numeric/.test(emsg)) {
          return this.createErrorResponseOptions(url, STATUS.UNPROCESSABLE_ENTRY, emsg);
        } else {
          return this.createErrorResponseOptions(
            url,
            STATUS.INTERNAL_SERVER_ERROR,
            `Failed to generate new id for '${collectionName}'`,
          );
        }
      }
    }

    if (id && id !== item.id) {
      return this.createErrorResponseOptions(
        url,
        STATUS.BAD_REQUEST,
        `Request id does not match item.id`,
      );
    } else {
      id = item.id;
    }
    const existingIx = this.indexOf(collection, id);
    const body = this.bodify(item);

    if (existingIx === -1) {
      collection.push(item);
      headers.set('Location', resourceUrl + '/' + id);
      return {headers, body, status: STATUS.CREATED};
    } else if (this.config.post409) {
      return this.createErrorResponseOptions(
        url,
        STATUS.CONFLICT,
        `'${collectionName}' item with id='${id} exists and may not be updated with POST; use PUT instead.`,
      );
    } else {
      collection[existingIx] = item;
      return this.config.post204
        ? {headers, status: STATUS.NO_CONTENT} // successful; no content
        : {headers, body, status: STATUS.OK}; // successful; return entity
    }
  }

  // Update existing entity
  // Can create an entity too if put404 is false.
  protected put({collection, collectionName, headers, id, req, url}: RequestInfo): ResponseOptions {
    const item = this.clone(this.getJsonBody(req));
    if (item.id == null) {
      return this.createErrorResponseOptions(
        url,
        STATUS.NOT_FOUND,
        `Missing '${collectionName}' id`,
      );
    }
    if (id && id !== item.id) {
      return this.createErrorResponseOptions(
        url,
        STATUS.BAD_REQUEST,
        `Request for '${collectionName}' id does not match item.id`,
      );
    } else {
      id = item.id;
    }
    const existingIx = this.indexOf(collection, id);
    const body = this.bodify(item);

    if (existingIx > -1) {
      collection[existingIx] = item;
      return this.config.put204
        ? {headers, status: STATUS.NO_CONTENT} // successful; no content
        : {headers, body, status: STATUS.OK}; // successful; return entity
    } else if (this.config.put404) {
      // item to update not found; use POST to create new item for this id.
      return this.createErrorResponseOptions(
        url,
        STATUS.NOT_FOUND,
        `'${collectionName}' item with id='${id} not found and may not be created with PUT; use POST instead.`,
      );
    } else {
      // create new item for id not found
      collection.push(item);
      return {headers, body, status: STATUS.CREATED};
    }
  }

  protected removeById(collection: any[], id: number) {
    const ix = this.indexOf(collection, id);
    if (ix > -1) {
      collection.splice(ix, 1);
      return true;
    }
    return false;
  }

  /**
   * Tell your in-mem "database" to reset.
   * returns Observable of the database because resetting it could be async
   */
  protected resetDb(reqInfo?: RequestInfo): Observable<boolean> {
    this.dbReadySubject && this.dbReadySubject.next(false);
    const db = this.inMemDbService.createDb(reqInfo);
    const db$ =
      db instanceof Observable
        ? db
        : typeof (db as any).then === 'function'
          ? from(db as Promise<any>)
          : of(db);
    db$.pipe(first()).subscribe((d: {}) => {
      this.db = d;
      this.dbReadySubject && this.dbReadySubject.next(true);
    });
    return this.dbReady;
  }
}
