import { isAffirmative, isNumber } from 'app/common/gutil';

/**
 * A bundle of settings for the application. May contain
 * a value directly, and/or via nested settings. Also
 * may have some information about where we looked for
 * the value, for reporting as a diagnostic.
 */
export class AppSettings {
  private _value?: JSONValue;
  private _children?: {[key: string]: AppSettings};
  private _info?: AppSettingQueryResult;

  public constructor(public readonly name: string) {}

  /* access the setting - undefined if not set */
  public get(): JSONValue|undefined {
    return this._value;
  }

  /* access the setting as a boolean using isAffirmative - undefined if not set */
  public getAsBool(): boolean|undefined {
    return (this._value !== undefined) ? isAffirmative(this._value) : undefined;
  }

  /**
   * Access the setting as an integer using parseInt. Undefined if not set.
   * Throws an error if not numberlike.
   */
  public getAsInt(): number|undefined {
    if (this._value === undefined) { return undefined; }
    const datum = this._value?.valueOf();
    if (typeof datum === 'number') {
      return datum;
    }
    if (isNumber(String(datum))) {
      return parseInt(String(datum), 10);
    }
    throw new Error(`${datum} does not look like a number`);
  }

  /**
   * Try to read the setting from the environment. Even if
   * we fail, we record information about how we tried to
   * find the setting, so we can report on that.
   */
  public read(query: AppSettingQuery) {
    this._value = undefined;
    this._info = undefined;
    let value = undefined;
    let found = false;
    const envVars = getEnvVarsFromQuery(query);
    if (!envVars.length) {
      throw new Error('could not find an environment variable to read');
    }
    let envVar = envVars[0];
    for (const synonym of envVars) {
      value = process.env[synonym];
      if (value !== undefined) {
        envVar = synonym;
        found = true;
        break;
      }
    }
    this._info = {
      envVar: found ? envVar : undefined,
      found,
      query,
    };
    if (value !== undefined) {
      this._value = value;
    } else if (query.defaultValue !== undefined) {
      this._value = query.defaultValue;
    }
    return this;
  }

  /**
   * As for read() but type the result as a string.
   */
  public readString(query: AppSettingQuery): string|undefined {
    this.read(query);
    if (this._value === undefined) { return undefined; }
    this._value = String(this._value);
    return this._value;
  }

  /**
   * As for readString() but fail if nothing was found.
   */
  public requireString(query: AppSettingQuery): string {
    const result = this.readString(query);
    if (result === undefined) {
      throw new Error(`missing environment variable: ${query.envVar}`);
    }
    return result;
  }

  /**
   * As for readInt() but fail if nothing was found.
   */
  public requireInt(query: AppSettingQuery): number {
    const result = this.readInt(query);
    if (result === undefined) {
      throw new Error(`missing environment variable: ${query.envVar}`);
    }
    return result;
  }

  /**
   * As for read() but type (and store, and report) the result as
   * a boolean.
   */
  public readBool(query: AppSettingQuery): boolean|undefined {
    this.readString(query);
    const result = this.getAsBool();
    this._value = result;
    return result;
  }

  /**
   * As for read() but type (and store, and report) the result as
   * an integer (well, a number).
   */
  public readInt(query: AppSettingQuery): number|undefined {
    this.readString(query);
    const result = this.getAsInt();
    this._value = result;
    return result;
  }

  /* set this setting 'manually' */
  public set(value: JSONValue): void {
    this._value = value;
    this._info = undefined;
  }

  /* access any nested settings */
  public get nested(): {[key: string]: AppSettings} {
    return this._children || {};
  }

  /**
   * Add a named nested setting, returning an AppSettings
   * object that can be used to access it. This method is
   * named "section" to suggest that the nested setting
   * will itself contain multiple settings, but doesn't
   * require that.
   */
  public section(fname: string): AppSettings {
    if (!this._children) { this._children = {}; }
    let child = this._children[fname];
    if (!child) {
      this._children[fname] = child = new AppSettings(fname);
    }
    return child;
  }

  /**
   * Add a named nested setting, returning an AppSettings
   * object that can be used to access it. This method is
   * named "flag" to suggest that tthe nested setting will
   * not iself be nested, but doesn't require that - it is
   * currently just an alias for the section() method.
   */
  public flag(fname: string): AppSettings {
    return this.section(fname);
  }

  /**
   * Produce a summary description of the setting and how it was
   * derived.
   */
  public describe(): AppSettingDescription {
    return {
      name: this.name,
      value: (this._info?.query.censor && this._value !== undefined) ? '*****' : this._value,
      foundInEnvVar: this._info?.envVar,
      wouldFindInEnvVar: this._info?.query.preferredEnvVar || getEnvVarsFromQuery(this._info?.query)[0],
      usedDefault: this._value !== undefined && this._info !== undefined && !this._info?.found,
    };
  }

  /**
   * As for describe(), but include all nested settings also.
   * Used dotted notation for setting names. Omit settings that
   * are undefined and without useful information about how they
   * might be defined.
   */
  public describeAll(): AppSettingDescription[] {
    const inv: AppSettingDescription[] = [];
    inv.push(this.describe());
    if (this._children) {
      for (const child of Object.values(this._children)) {
        for (const item of child.describeAll()) {
          inv.push({...item, name: this.name + '.' + item.name});
        }
      }
    }
    return inv.filter(item => item.value !== undefined ||
      item.wouldFindInEnvVar !== undefined ||
      item.usedDefault);
  }
}

/**
 * A global object for Grist application settings.
 */
export const appSettings = new AppSettings('grist');

/**
 * Hints for how to define a setting, including possible
 * environment variables and default values.
 */
export interface AppSettingQuery {
  envVar: string|string[];  // environment variable(s) to check.
  preferredEnvVar?: string; // "Canonical" environment variable to suggest.
                            // Should be in envVar (though this is not checked).
  defaultValue?: JSONValue; // value to use if variable(s) unavailable.
  censor?: boolean;   // should the value of the setting be obscured when printed.
}

/**
 * Result of a query specifying whether the setting
 * was found, and if so in what environment variable, and using
 * what query.
 */
export interface AppSettingQueryResult {
  envVar?: string;
  found: boolean;
  query: AppSettingQuery;
}

/**
 * Output of AppSettings.describe().
 */
interface AppSettingDescription {
  name: string;            // name of the setting.
  value?: JSONValue;       // value of the setting, if available.
  foundInEnvVar?: string;  // environment variable the setting was read from, if available.
  wouldFindInEnvVar?: string;  // environment variable that would be checked for the setting.
  usedDefault: boolean;    // whether a default value was used for the setting.
}

// Helper function to normalize the AppSettingQuery.envVar list.
function getEnvVarsFromQuery(q?: AppSettingQuery): string[] {
  if (!q) { return []; }
  return Array.isArray(q.envVar) ? q.envVar : [q.envVar];
}

// Keep app settings JSON-like, in case later we decide to load them from
// a JSON source.
type JSONValue = string | number | boolean | null | { [member: string]: JSONValue } | JSONValue[];