/* eslint-disable @typescript-eslint/no-explicit-any */
/*
 * Copyright (C) 2019-2022 Open 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 equal from 'fast-deep-equal/es6/index.js';
import clone from 'rfdc';
import {SCLanguageCode} from './general/i18n.js';
import {isThing} from './guards.js';
import {SCClasses} from './meta.js';
import {SCThing, SCThingType} from './things/abstract/thing.js';

// eslint disable @typescript-eslint/no-explicit-any
const standardCacheSize = 200;

/**
 * SCThingTranslator class
 */
export class SCThingTranslator {
  /**
   * Getter for language property
   */
  get language(): SCLanguageCode {
    return this._language;
  }

  /**
   * Setter for language property. Also flushes translation cache
   * @param language The language the translator instance will use from now on
   */
  set language(language: SCLanguageCode) {
    if (language !== this._language) {
      this.cache.flush();
    }
    this._language = language;
  }

  /**
   * Property representing the translators target language
   */
  private _language: SCLanguageCode;

  /**
   * LRU cache containing already translated SCThings
   */
  private readonly cache: LRUCache<SCThing>;

  /**
   * Property providing a mapping from a SCThingType to its known own meta class
   */
  private readonly metaClasses: typeof SCClasses;

  /**
   * LRU cache containing SCThings translations have been provided for
   */
  private readonly sourceCache: LRUCache<SCThing>;

  /**
   * @example
   * // returns translator instance for german
   * new SCThingTranslator('de');
   */
  constructor(language: SCLanguageCode, cacheCapacity: number = standardCacheSize) {
    this.cache = new LRUCache(cacheCapacity);
    this.sourceCache = new LRUCache(cacheCapacity);
    this._language = language;
    this.metaClasses = SCClasses;

    // Initalize all meta classes once
    if (typeof (this.metaClasses as any)[Object.keys(this.metaClasses)[0]] === 'function') {
      for (const metaClass of Object.keys(this.metaClasses)) {
        (this.metaClasses as any)[metaClass] = new (SCClasses as any)[metaClass]();
      }
    }
  }

  /**
   * Get field value translation recursively
   * @param data The intermediate object / primitive returned by the Proxys get() method
   * @returns a T object allowing for access to translations or a translated value(s)
   */
  private deeptranslate<T>(data: T): T {
    return typeof data === 'object'
      ? (new Proxy(data as never, {
          get: (target, key) => this.deeptranslate(target[key as never]),
        }) as unknown as T)
      : data;
  }

  /**
   * Applies only known field translations of the given SCThings meta class to an instance
   * @param thingType The type of thing that will be translated
   * @param language The language the thing property values are translated to
   * @returns The thing with all known meta values translated
   */
  private getAllMetaFieldTranslations(thingType: SCThingType, language: SCLanguageCode): object | undefined {
    const fieldTranslations = {};
    const metaClass = this.getMetaClassInstance(thingType);
    if (metaClass === undefined) {
      return undefined;
    }

    // Assigns every property in fieldTranslations to the known base language translation
    if (metaClass.fieldTranslations.en !== undefined) {
      for (const key of Object.keys(metaClass.fieldTranslations.en)) {
        (fieldTranslations as any)[key] = metaClass.fieldTranslations.en[key];
      }
    }

    // Assigns every property in fieldTranslations to the known translation in given language
    if (metaClass.fieldTranslations[language] !== undefined) {
      for (const key of Object.keys(metaClass.fieldTranslations[language])) {
        (fieldTranslations as any)[key] = metaClass.fieldTranslations[language][key];
      }
    }

    return fieldTranslations;
  }

  /**
   * Returns meta class needed for translations given a SCThingType
   * @param thingType Type of the thing
   * @returns An instance of the metaclass
   */
  private getMetaClassInstance(thingType: SCThingType): any {
    if (thingType in this.metaClasses) {
      return this.metaClasses[thingType];
    }

    return undefined;
  }

  /**
   * Applies known field value translations of the given SCThings meta class to an instance
   * Translated values overwrite current values inplace (destructive)
   * @param instance The thing / object that will be translated
   * @param language The language the thing / object is translated to
   * @returns The thing with translated meta field values
   */
  private replaceAvailableMetaFieldValueTranslations(instance: any, language: SCLanguageCode): any {
    const metaClass = this.getMetaClassInstance(instance.type);
    if (metaClass === undefined) {
      return instance;
    }
    if (metaClass.fieldValueTranslations[language] !== undefined) {
      for (const key of Object.keys(metaClass.fieldValueTranslations[language])) {
        if (
          metaClass.fieldValueTranslations[language][key] instanceof Object &&
          (instance as any)[key] instanceof Object
        ) {
          // Assigns known translations of subproperties to property in given language (e.g. categories)
          for (const subKey of Object.keys((instance as any)[key])) {
            (instance as any)[key][subKey] =
              metaClass.fieldValueTranslations[language][key][(instance as any)[key][subKey]];
          }
        } else if (
          metaClass.fieldValueTranslations[language][key] instanceof Object &&
          typeof (instance as any)[key] === 'string'
        ) {
          // Assigns known translations of enum to property in given language (e.g. SCSettingInputType)
          (instance as any)[key] = metaClass.fieldValueTranslations[language][key][(instance as any)[key]];
        } else {
          // Assigns property to known translation of fieldValueTranslations in given language
          (instance as any)[key] = metaClass.fieldValueTranslations[language][key];
        }
      }
    }

    return instance;
  }

  /**
   * Recursively translates the given object in-place
   * Translated values overwrite current values (destructive)
   * @param instance The thing / object that will be translated
   * @returns The thing translated
   */
  private translateThingInPlaceDestructively<T>(instance: T): T {
    const targetLanguage = this.language;

    let nextInstance = instance as any;
    // Recursively call this function on all nested SCThings, arrays and objects
    for (const key of Object.keys(nextInstance)) {
      if (
        isThing(nextInstance[key]) ||
        Array.isArray(nextInstance[key]) ||
        nextInstance[key] instanceof Object
      ) {
        nextInstance[key] = this.translateThingInPlaceDestructively(nextInstance[key]);
      }
    }

    // Spread variable translations given by the connector into thing
    if (nextInstance.translations?.[targetLanguage] !== undefined) {
      nextInstance = {...nextInstance, ...nextInstance.translations![targetLanguage]} as T;
    }
    // Spread known translations from meta classes into (partly) translated thing
    this.replaceAvailableMetaFieldValueTranslations(nextInstance, targetLanguage);

    return nextInstance;
  }

  /**
   * Recursively translates the given object in-place
   * Translated values overwrite current values (destructive)
   * @param thing The thing / object that will be translated
   * @returns The thing translated
   */
  public translate<T extends SCThing>(thing: T): T {
    if (equal(this.sourceCache.get(thing), thing)) {
      const cachedInstance = this.cache.get(thing);
      if (cachedInstance !== undefined) {
        return cachedInstance as T;
      }
    }
    const translatedInstance = this.translateThingInPlaceDestructively(clone()(thing));
    delete translatedInstance.translations;
    this.cache.putObject(translatedInstance);
    this.sourceCache.putObject(thing);

    return translatedInstance as T;
  }

  /**
   * Get field value translation recursively
   * @example
   * const dish: SCDish = {...};
   * translator.translate(dish).offers[0].inPlace.categories[1]());
   * // or
   * const dishTranslatedAccess = translator.translate(dish);
   * dishTranslatedAccess.offers[0].inPlace.categories[1]();
   * // undoing the TSOCType<T>
   * const dishAsBefore: SCDish = dishTranslatedAccess()!;
   * @param thing Top level object that gets passed through the recursion
   * @returns an TSOCType<T> object allowing for access to translations or a translated value(s)
   */
  public translatedAccess<T extends SCThing>(thing: T): T {
    return new Proxy(thing, {
      get: (target, key) => {
        const object: any = target;
        if (equal(this.sourceCache.get(thing), thing)) {
          const objectTranslatedFromCache = this.cache.get(thing);
          if (objectTranslatedFromCache !== undefined) {
            return this.deeptranslate((objectTranslatedFromCache as any)[key]);
          }
        }
        const objectTranslated = this.translateThingInPlaceDestructively(clone()(object));
        this.cache.putObject(objectTranslated);
        this.sourceCache.putObject(thing);

        return this.deeptranslate(objectTranslated[key]);
      },
    });
  }

  /**
   * Given a SCThingType this function returns an object with the same basic structure as the corresponding SCThing
   * All the values will be set to the known translations of the property/key name
   * @example
   * const translatedMetaDish = translator.translatedPropertyNames<SCCourseOfStudy>(SCThingType.CourseOfStudy);
   * @param type The type whose property names will be translated
   * @returns An object with the properties of the SCThingType where the values are the known property tranlations
   */
  public translatedPropertyNames<T extends SCThing>(type: SCThingType): T | undefined {
    return this.getAllMetaFieldTranslations(type, this.language) as T;
  }

  /**
   * Given a SCThingType and a corresponding property name it returns the known property value translation
   * Access pattern to the meta object containing the translation can be thought of as type.field[key] with key being optional
   * @example
   * const singleValueTranslation = translator.translatedPropertyValue(SCThingType.Dish, 'categories', 'main dish');
   * @param type The type for whose property values a translation is required
   * @param field The property for which a translation is required
   * @param key If specified tries to access the field with this key
   * @returns Known translation for the property
   */
  public translatedPropertyValue(type: SCThingType, field: string, key?: string): string | undefined {
    const fieldValueTranslations =
      this.getMetaClassInstance(type).fieldValueTranslations[this.language] ??
      this.getMetaClassInstance(type).fieldValueTranslations.en;
    const fieldTranslation = fieldValueTranslations?.[field];

    return fieldTranslation?.[key ?? ''] ?? key ?? fieldTranslation;
  }
}

/**
 * LRUCache class
 * Small last recently used cache intended to get used by SCThingTranslator
 */
class LRUCache<T> {
  /**
   * Map property that manages cached content
   */
  private readonly entries: Map<string, T> = new Map<string, T>();

  /**
   * Property representing cache maximum capacity
   */
  private readonly maxEntries: number;

  /**
   * @example
   * // returns LRUCache instance with a maximum capacity of 500
   * new LRUCache(500);
   */
  constructor(maxEntries: number) {
    this.maxEntries = maxEntries;
  }

  /**
   * Flushes cache / removes all entries
   */
  public flush() {
    this.entries.clear();
  }

  /**
   * Get content from cache by key
   */
  public get(somethingOrKey: string): T | undefined;

  /**
   * Get content from cache by another objects uid
   */
  public get<U extends SCThing>(something: U): T | undefined;

  /**
   * Get content from cache by key or by another objects uid
   * @param somethingOrKey The key which maps to the cached content or an object for which content has been cached
   * @returns If available the content connected to the key or somethingOrKey.uid property
   */
  public get<U extends SCThing>(somethingOrKey: string | U): T | undefined {
    let key: string;
    if (typeof somethingOrKey === 'string') {
      key = somethingOrKey;
    } else if (isThing(somethingOrKey)) {
      key = somethingOrKey.uid;
    } else {
      throw new Error(`Passed argument ${somethingOrKey} cannot be key in LRUCache`);
    }

    const entry = this.entries.get(key);
    if (entry !== undefined) {
      // LRU behavior
      this.entries.delete(key);
      this.entries.set(key, entry);
    }

    return entry;
  }

  /**
   * Place content in cache by key
   * @param key The key for which content should be cached
   * @param content The content that should be cached
   */
  public put(key: string, content: T) {
    if (this.entries.size >= this.maxEntries) {
      // LRU behavior
      const keyToDelete = this.entries.keys().next().value;
      this.entries.delete(keyToDelete);
    }
    this.entries.set(key, content);
  }

  /**
   * Place content in cache by another objects uid
   * @param something The object that should be cached under something.uid
   */
  public putObject<U extends SCThing>(something: U) {
    this.put(something.uid, something as any as T);
  }
}
