/*
 * Copyright (C) 2022 StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * 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 General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
import {Injectable} from '@angular/core';
import {Client} from '@openstapps/api';
import {
  SCFacet,
  SCIndexableThings,
  SCMultiSearchRequest,
  SCMultiSearchResponse,
  SCSearchRequest,
  SCSearchResponse,
  SCSearchValueFilter,
  SCThing,
  SCThingOriginType,
  SCThings,
  SCThingsField,
  SCThingType,
  SCSaveableThing,
  SCFeedbackRequest,
  SCFeedbackResponse,
  SCUuid,
} from '@openstapps/core';
import {environment} from '../../../environments/environment';
import {StorageProvider} from '../storage/storage.provider';
import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
import {chunk} from '@openstapps/collection-utils';

export enum DataScope {
  Local = 'local',
  Remote = 'remote',
}

interface CacheItem<T> {
  data: Promise<T>;
  timestamp: number;
}

/**
 *  Generated class for the DataProvider provider.
 *
 * See https://angular.io/guide/dependency-injection for more info on providers
 * and Angular DI.
 */
@Injectable({
  providedIn: 'root',
})
export class DataProvider {
  /**
   * TODO
   */
  get storagePrefix(): string {
    return this._storagePrefix;
  }

  /**
   * TODO
   */
  set storagePrefix(storagePrefix) {
    this._storagePrefix = storagePrefix;
  }

  /**
   * TODO
   */
  private _storagePrefix = 'stapps.data';

  readonly cache: Record<SCUuid, CacheItem<SCThings> | undefined> = {};

  private maxCacheAge = 3600;

  /**
   * Version of the app (used for the header in communication with the backend)
   */
  appVersion = environment.backend_version;

  /**
   * Maximum number of sub-queries in a multi-query allowed by the backend
   */
  backendQueriesLimit = 5;

  /**
   * TODO
   */
  backendUrl = environment.backend_url;

  /**
   * TODO
   */
  client: Client;

  /**
   * TODO
   */
  storageProvider: StorageProvider;

  /**
   * Simplify creation of a value filter
   * @param field Database field for apply the filter to
   * @param value Value to match with
   */
  static createValueFilter(field: SCThingsField, value: string): SCSearchValueFilter {
    return {
      type: 'value',
      arguments: {
        field: field,
        value: value,
      },
    };
  }

  /**
   * Create a facet from data
   * @param items Data to generate facet for
   * @param field Field for which to generate facet
   */
  static facetForField(items: SCThing[], field: SCThingsField): SCFacet {
    const bucketMap = new Map<string, number>();
    const facet: SCFacet = {buckets: [], field: field};

    for (const item of items) {
      const value = bucketMap.get(item.type) === undefined ? 1 : (bucketMap.get(item.type) as number) + 1;
      bucketMap.set(item.type, value);
    }

    for (const [key, value] of bucketMap.entries()) {
      facet.buckets.push({key: key, count: value});
    }

    return facet;
  }

  /**
   * TODO
   * @param stAppsWebHttpClient TODO
   * @param storageProvider TODO
   */
  constructor(stAppsWebHttpClient: StAppsWebHttpClient, storageProvider: StorageProvider) {
    this.client = new Client(stAppsWebHttpClient, this.backendUrl, this.appVersion);
    this.storageProvider = storageProvider;
  }

  /**
   * Create savable thing from an indexable thing
   * @param item An indexable to create savable thing from
   * @param type The type (falls back to the type of the indexable thing)
   */
  static createSaveable(item: SCIndexableThings, type?: SCThingType): SCSaveableThing {
    return {
      data: item,
      name: item.name,
      origin: {
        created: new Date().toISOString(),
        type: SCThingOriginType.User,
      },
      type: type === undefined ? item.type : type,
      uid: item.uid,
    };
  }

  /**
   * Delete a data item
   * @param uid Unique identifier of the saved data item
   */
  async delete(uid: string): Promise<void> {
    return this.storageProvider.delete(this.getDataKey(uid));
  }

  /**
   * Delete all the previously saved data items
   */
  async deleteAll(): Promise<void> {
    const keys = [...(await this.getAll()).keys()];

    return this.storageProvider.delete(...keys);
  }

  /**
   * Provides a savable thing from the local database using the provided UID
   */
  async get(uid: string, scope: DataScope.Local): Promise<SCSaveableThing>;
  /**
   * Provides a thing from the backend
   */
  async get(uid: string, scope: DataScope.Remote): Promise<SCThings | SCSaveableThing>;
  /**
   * Provides a thing from both local database and backend
   */
  async get(uid: string): Promise<Map<DataScope, SCThings | SCSaveableThing>>;

  /**
   * Provides a thing from the local database only, backend only or both, depending on the scope
   * @param uid Unique identifier of a thing
   * @param scope From where data should be provided
   */
  async get(
    uid: string,
    scope?: DataScope,
  ): Promise<SCThings | SCSaveableThing | Map<DataScope, SCThings | SCSaveableThing>> {
    if (scope === DataScope.Local) {
      return this.storageProvider.get<SCSaveableThing>(this.getDataKey(uid));
    }
    if (scope === DataScope.Remote) {
      const timestamp = Date.now();
      const cacheItem = this.cache[uid];
      if (cacheItem && timestamp - cacheItem.timestamp < this.maxCacheAge) {
        return cacheItem.data;
      }
      const item = this.client.getThing(uid);
      this.cache[uid] = {
        data: item,
        timestamp: timestamp,
      };
      return item;
    }
    const map: Map<DataScope, SCThings | SCSaveableThing> = new Map();
    map.set(DataScope.Local, await this.get(uid, DataScope.Local));
    map.set(DataScope.Remote, await this.get(uid, DataScope.Remote));

    return map;
  }

  /**
   * Provides all things saved in the local database
   */
  async getAll(): Promise<Map<string, SCSaveableThing>> {
    return this.storageProvider.search<SCSaveableThing>(this.storagePrefix);
  }

  /**
   * Provides key for storing data into the local database
   * @param uid Unique identifier of a resource
   */
  getDataKey(uid: string): string {
    return `${this.storagePrefix}.${uid}`;
  }

  /**
   * Provides information if something with an UID is saved as a data item
   * @param uid Unique identifier of the saved data item
   */
  async isSaved(uid: string): Promise<boolean> {
    return this.storageProvider.has(this.getDataKey(uid));
  }

  /**
   * Performs multiple searches at once and returns their responses
   * @param query - query to send to the backend (auto-splits according to the backend limit)
   */
  async multiSearch(query: SCMultiSearchRequest): Promise<SCMultiSearchResponse> {
    // partition object into chunks, process those requests in parallel, then merge their responses again
    return Object.assign(
      {},
      ...(await Promise.all(
        chunk(Object.entries(query), this.backendQueriesLimit).map(request =>
          this.client.multiSearch(Object.fromEntries(request)),
        ),
      )),
    );
  }

  /**
   * Save a data item
   * @param item An item that needs to be saved
   */
  async put(item: SCIndexableThings): Promise<SCSaveableThing> {
    return this.storageProvider.put<SCSaveableThing>(
      this.getDataKey(item.uid),
      DataProvider.createSaveable(item, item.type),
    );
  }

  /**
   * Send a feedback message (request)
   * @param feedback Feedback message to be sent to the backend
   */
  async sendFeedback(feedback: SCFeedbackRequest) {
    return this.client.invokePlugin<SCFeedbackResponse>('feedback', undefined, feedback);
  }

  /**
   * Searches the backend using the provided query and returns response
   * @param query - query to send to the backend
   */
  async search(query: SCSearchRequest): Promise<SCSearchResponse> {
    return this.client.search(query);
  }
}
