/*
 * 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 {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core';
import deepMerge from 'deepmerge';
import {Subject} from 'rxjs';
import {ConfigProvider} from '../config/config.provider';
import {StorageProvider} from '../storage/storage.provider';

export const STORAGE_KEY_SETTINGS = 'settings';
export const STORAGE_KEY_SETTINGS_SEPARATOR = '.';
export const STORAGE_KEY_SETTING_VALUES = `${STORAGE_KEY_SETTINGS}${STORAGE_KEY_SETTINGS_SEPARATOR}values`;

/**
 * Category structure of settings cache
 */
export interface CategoryWithSettings {
  /**
   * Category name
   */
  category: string;
  /**
   * Settings that belong in this category
   */
  settings: {[key: string]: SCSetting};
}

/**
 * Structure of SettingsCache
 */
export interface SettingsCache {
  [key: string]: CategoryWithSettings;
}

/**
 * Structure with categories and its setting valueContainers for persistence
 */
export interface SettingValuesContainer {
  [key: string]: SettingValueContainer;
}

/**
 * Structure of a setting and its value
 */
export interface SettingValueContainer {
  [key: string]: SCSettingValue | SCSettingValue[] | undefined;
}

/**
 * Structure of the settings events
 */
export interface SettingsAction {
  /**
   * Data related to the action
   */
  payload?: {
    /**
     * Setting category
     */
    category: string;
    /**
     * Setting name
     */
    name: string;
    /**
     * Setting value
     */
    value: SCSettingValue | SCSettingValues;
  };

  /**
   * Type of the settings action
   */
  type: string;
}

/**
 * Provider for app settings
 */
@Injectable()
export class SettingsProvider {
  /**
   * Source of settings actions
   */
  private settingsActionSource = new Subject<SettingsAction>();

  private needsInit = true;

  /**
   * Order of the setting categories
   */
  categoriesOrder: string[];

  /**
   * Settings actions observable
   */
  settingsActionChanged$ = this.settingsActionSource.asObservable();

  /**
   * Cache for the imported settings
   */
  settingsCache: SettingsCache;

  /**
   * Return true if all given values are valid to possible values in given settingInput
   * @param possibleValues Possible values
   * @param enteredValues Entered value
   */
  public static checkMultipleChoiceValue(
    possibleValues: SCSettingValues | undefined,
    enteredValues: SCSettingValues,
  ): boolean {
    if (possibleValues === undefined) {
      return false;
    }

    for (const value of enteredValues) {
      if (!possibleValues.includes(value)) {
        return false;
      }
    }

    return true;
  }

  /**
   * Returns true if given value is valid to possible values in given settingInput
   * @param possibleValues  Possible values
   * @param enteredValue Entered value
   */
  public static checkSingleChoiceValue(
    possibleValues: SCSettingValues | undefined,
    enteredValue: SCSettingValue,
  ): boolean {
    if (possibleValues === undefined) {
      return false;
    }

    return (
      possibleValues !== undefined && Array.isArray(possibleValues) && possibleValues.includes(enteredValue)
    );
  }

  /**
   * Validates value for given settings inputType. Returns true if value is valid.
   * @param setting setting to check value against
   * @param value value to validate
   */
  public static validateValue(setting: SCSetting, value: SCSettingValue | SCSettingValues): boolean {
    let isValueValid = false;
    switch (setting.inputType) {
      case 'number': {
        if (typeof value === 'number') {
          isValueValid = true;
        }
        break;
      }
      case 'multiple choice': {
        isValueValid = Array.isArray(value)
          ? SettingsProvider.checkMultipleChoiceValue(setting.values, value)
          : false;
        break;
      }
      case 'password':
      case 'text': {
        if (typeof value === 'string') {
          isValueValid = true;
        }
        break;
      }
      case 'single choice': {
        isValueValid = Array.isArray(value)
          ? false
          : SettingsProvider.checkSingleChoiceValue(setting.values, value);
        break;
      }
      default:
    }

    return isValueValid;
  }

  /**
   *
   * @param storage TODO
   * @param configProvider TODO
   */
  constructor(
    private readonly storage: StorageProvider,
    private readonly configProvider: ConfigProvider,
  ) {
    this.categoriesOrder = [];
    this.settingsCache = {};
  }

  /**
   * Add an Setting to the Cache if not exist and set undefined value to defaultValue
   * @param setting Setting with categories, defaultValue, name, input type and valid values
   */
  private addSetting(setting: SCSetting): void {
    if (!this.categoryExists(setting.categories[0])) {
      this.provideCategory(setting.categories[0]);
    }
    if (!this.settingExists(setting.categories[0], setting.name)) {
      if (setting.value === undefined) {
        setting.value = setting.defaultValue;
      }
      this.settingsCache[setting.categories[0]].settings[setting.name] = setting;
    }
  }

  /**
   * Returns all setting values from settingsCache in a SettingsValueContainer
   */
  private getSettingValuesFromCache(): SettingValuesContainer {
    const settingValuesContainer: SettingValuesContainer = {};
    // iterate through keys of categories
    for (const categoryKey of Object.keys(this.settingsCache)) {
      // iterate through keys of settingValueContainer
      for (const settingKey of Object.keys(this.settingsCache[categoryKey].settings)) {
        if (settingValuesContainer[categoryKey] === undefined) {
          settingValuesContainer[categoryKey] = {};
        }
        settingValuesContainer[categoryKey][settingKey] =
          this.settingsCache[categoryKey].settings[settingKey].value;
      }
    }

    return settingValuesContainer;
  }

  /**
   * Add category if not exists
   * @param category the category to provide
   */
  private provideCategory(category: string): void {
    if (!this.categoryExists(category)) {
      if (!this.categoriesOrder.includes(category)) {
        this.categoriesOrder.push(category);
      }
      this.settingsCache[category] = {
        category: category,
        settings: {},
      };
    }
  }

  /**
   * Returns true if category exists
   * @param category Category key name
   */
  public categoryExists(category: string): boolean {
    return this.settingsCache[category] !== undefined;
  }

  /**
   * Returns copy of cached settings
   */
  public async getCache(): Promise<SettingsCache> {
    await this.init();

    return structuredClone(this.settingsCache);
  }

  /**
   * Returns an array with the order of categories
   */
  public getCategoriesOrder(): string[] {
    return this.categoriesOrder;
  }

  /**
   * Returns copy of a setting if exist
   * @param category the category of requested setting
   * @param name the name of requested setting
   * @throws Exception if setting is not provided
   */
  public async getSetting(category: string, name: string): Promise<SCSetting> {
    await this.init();
    if (this.settingExists(category, name)) {
      // return a copy of the settings
      return structuredClone(this.settingsCache[category].settings[name]);
    }
    throw new Error(`Setting "${name}" not provided`);
  }

  /**
   * Returns copy of a settings value if exist
   * @param category the category of requested setting
   * @param name the name of requested setting
   * @throws Exception if setting is not provided
   */
  public async getValue(category: string, name: string): Promise<SCSettingValue | SCSettingValues> {
    await this.init();
    if (this.settingExists(category, name)) {
      // return a copy of the settings value
      return structuredClone(this.settingsCache[category].settings[name].value)!;
    }
    throw new Error(`Setting "${name}" not provided`);
  }

  /**
   * Initializes settings from config and stored values if exist
   */
  public async init(): Promise<void> {
    if (!this.needsInit) return;
    this.needsInit = false;

    try {
      const settings: SCSetting[] = this.configProvider.getValue('settings') as SCSetting[];
      for (const setting of settings) this.addSetting(setting);

      for (const category of Object.keys(this.settingsCache)) {
        if (!this.categoriesOrder.includes(category)) {
          this.categoriesOrder.push(category);
        }
      }
    } catch {
      this.settingsCache = {};
    }

    if (await this.storage.has(STORAGE_KEY_SETTING_VALUES)) {
      // get setting values from StorageProvider into settingsCache
      const valuesContainer: SettingValuesContainer =
        await this.storage.get<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES);
      // iterate through keys of categories
      for (const categoryKey of Object.keys(this.settingsCache)) {
        // iterate through setting keys of category
        for (const settingKey of Object.keys(this.settingsCache[categoryKey].settings)) {
          // if saved setting value exists set it, otherwise set to default value
          this.settingsCache[categoryKey].settings[settingKey].value =
            valuesContainer[categoryKey] !== undefined &&
            valuesContainer[categoryKey][settingKey] !== undefined
              ? valuesContainer[categoryKey][settingKey]
              : this.settingsCache[categoryKey].settings[settingKey].defaultValue;
        }
      }
      await this.saveSettingValues();
    }
  }

  /**
   * Adds given setting and its category if not exist
   * @param setting the setting to add
   */
  public provideSetting(setting: SCSetting): void {
    this.addSetting(setting);
  }

  /**
   * Deletes saved values and reinitialising the settings
   */
  public async reset(): Promise<void> {
    await this.storage.put(STORAGE_KEY_SETTING_VALUES, {});
    this.needsInit = true;
    await this.init();
  }

  /**
   * Sets values of all settings to defaultValue
   */
  async resetDefault(): Promise<void> {
    for (const catKey of Object.keys(this.settingsCache)) {
      for (const settingKey of Object.keys(this.settingsCache[catKey].settings)) {
        const settingInput = this.settingsCache[catKey].settings[settingKey];
        settingInput.value = settingInput.defaultValue;
      }
    }
    await this.saveSettingValues();
  }

  /**
   * Saves cached settings in app storage
   */
  public async saveSettingValues(): Promise<void> {
    if (await this.storage.has(STORAGE_KEY_SETTING_VALUES)) {
      const savedSettingsValues: SettingValuesContainer =
        await this.storage.get<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES);
      const cacheSettingsValues = this.getSettingValuesFromCache();
      const mergedSettingValues = deepMerge(savedSettingsValues, cacheSettingsValues);
      await this.storage.put<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES, mergedSettingValues);
    } else {
      await this.storage.put<SettingValuesContainer>(
        STORAGE_KEY_SETTING_VALUES,
        this.getSettingValuesFromCache(),
      );
    }
  }

  /**
   * Sets the order the given categories show up in the settings page
   * @param categoryNames the order of the categories
   */
  public setCategoriesOrder(categoryNames: string[]) {
    this.categoriesOrder = categoryNames;
  }

  /**
   * Sets a valid value of a setting and persists changes in storage. Also the changes get published bey Events
   * @param category Category key name
   * @param name Setting key name
   * @param value Value to be set
   * @throws Exception if setting is not provided or value not valid to the settings inputType
   */
  public async setSettingValue(
    category: string,
    name: string,
    value: SCSettingValue | SCSettingValues,
  ): Promise<void> {
    await this.init();
    if (this.settingExists(category, name)) {
      const setting: SCSetting = this.settingsCache[category].settings[name];
      const isValueValid = SettingsProvider.validateValue(setting, value);
      if (isValueValid) {
        // set and persist new value
        this.settingsCache[category].settings[name].value = value;
        await this.saveSettingValues();
        // publish setting changes
        this.settingsActionSource.next({
          type: 'stapps.settings.changed',
          payload: {category, name, value},
        });
      } else {
        throw new Error(`Value "${value}" of type ${typeof value}
        is not valid for "${setting.inputType}" of "${category}.${name}".
        Ommiting change`);
      }
    } else {
      throw new Error(`Setting "${name}" doesn't exisits within "${category}"`);
    }
  }

  /**
   * Returns true if setting in category exists
   * @param category Category key name
   * @param setting Setting key name
   */
  public settingExists(category: string, setting: string): boolean {
    return this.categoryExists(category) && this.settingsCache[category].settings[setting] !== undefined;
  }
}
