/*
 This file is part of GNU Taler
 (C) 2020-2023 Taler Systems S.A.
 GNU Taler 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; either version 3, or (at your option) any later version.
 GNU Taler 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
 GNU Taler; see the file COPYING.  If not, see 
 */
/**
 * Utilities to handle Taler-style configuration files.
 *
 * @author Florian Dold 
 */
/**
 * Imports
 */
import { AmountJson } from "./amounts.js";
import { Amounts } from "./amounts.js";
import { Logger } from "./logging.js";
import nodejs_path from "path";
import nodejs_os from "os";
import nodejs_fs from "fs";
const logger = new Logger("talerconfig.ts");
export class ConfigError extends Error {
  constructor(message: string) {
    super();
    Object.setPrototypeOf(this, ConfigError.prototype);
    this.name = "ConfigError";
    this.message = message;
  }
}
enum EntryOrigin {
  /**
   * From a default file.
   */
  DefaultFile = 1,
  /**
   * From a system/installation specific default value.
   */
  DefaultSystem = 2,
  /**
   * Loaded from file or string
   */
  Loaded = 3,
  /**
   * Changed after loading
   */
  Changed = 4,
}
interface Entry {
  value: string;
  sourceLine: number;
  sourceFile: string;
  origin: EntryOrigin;
}
interface Section {
  secretFilename?: string;
  inaccessible: boolean;
  entries: { [optionName: string]: Entry };
}
type SectionMap = { [sectionName: string]: Section };
export class ConfigValue {
  constructor(
    private sectionName: string,
    private optionName: string,
    private value: string | undefined,
    private converter: (x: string) => T,
  ) { }
  required(): T {
    if (this.value == undefined) {
      throw new ConfigError(
        `required option [${this.sectionName}]/${this.optionName} not found`,
      );
    }
    return this.converter(this.value);
  }
  orUndefined(): T | undefined {
    if (this.value !== undefined) {
      return this.converter(this.value);
    } else {
      return undefined;
    }
  }
  orDefault(v: T): T | undefined {
    if (this.value !== undefined) {
      return this.converter(this.value);
    } else {
      return v;
    }
  }
  isDefined(): boolean {
    return this.value !== undefined;
  }
  getValue(): string | undefined {
    return this.value
  }
}
/**
 * Expand a path by resolving the tilde syntax for home directories
 * and by making relative paths absolute based on the current working directory.
 */
export function expandPath(path: string): string {
  if (path[0] === "~") {
    path = nodejs_path.join(nodejs_os.homedir(), path.slice(1));
  }
  if (path[0] !== "/") {
    path = nodejs_path.join(process.cwd(), path);
  }
  return path;
}
/**
 * Shell-style path substitution.
 *
 * Supported patterns:
 * "$x" (look up "x")
 * "${x}" (look up "x")
 * "${x:-y}" (look up "x", fall back to expanded y)
 */
export function pathsub(
  x: string,
  lookup: (s: string, depth: number) => string | undefined,
  recursionDepth = 0,
): string {
  if (recursionDepth >= 128) {
    throw Error("recursion in path substitution");
  }
  let s = x;
  let l = 0;
  while (l < s.length) {
    if (s[l] === "$") {
      if (s[l + 1] === "{") {
        let depth = 1;
        const start = l;
        let p = start + 2;
        let insideNamePart = true;
        let hasDefault = false;
        for (; p < s.length; p++) {
          if (s[p] == "}") {
            insideNamePart = false;
            depth--;
          } else if (s[p] === "$" && s[p + 1] === "{") {
            insideNamePart = false;
            depth++;
          }
          if (insideNamePart && s[p] === ":" && s[p + 1] === "-") {
            hasDefault = true;
          }
          if (depth == 0) {
            break;
          }
        }
        if (depth == 0) {
          const inner = s.slice(start + 2, p);
          let varname: string;
          let defaultValue: string | undefined;
          if (hasDefault) {
            [varname, defaultValue] = inner.split(":-", 2);
          } else {
            varname = inner;
            defaultValue = undefined;
          }
          const r = lookup(inner, depth + 1);
          if (r !== undefined) {
            s = s.substring(0, start) + r + s.substring(p + 1);
            l = start + r.length;
            continue;
          } else if (defaultValue !== undefined) {
            const resolvedDefault = pathsub(defaultValue, lookup, depth + 1);
            s = s.substring(0, start) + resolvedDefault + s.substring(p + 1);
            l = start + resolvedDefault.length;
            continue;
          }
        }
        l = p;
        continue;
      } else {
        const m = /^[a-zA-Z-_][a-zA-Z0-9-_]*/.exec(s.substring(l + 1));
        if (m && m[0]) {
          const r = lookup(m[0], recursionDepth + 1);
          if (r !== undefined) {
            s = s.substring(0, l) + r + s.substring(l + 1 + m[0].length);
            l = l + r.length;
            continue;
          }
        }
      }
    }
    l++;
  }
  return s;
}
export interface LoadOptions {
  filename?: string;
  banDirectives?: boolean;
}
export interface StringifyOptions {
  diagnostics?: boolean;
  excludeDefaults?: boolean;
}
export interface LoadedFile {
  filename: string;
  level: number;
}
/**
 * Check for a simple wildcard match.
 * Only asterisks are allowed.
 * Asterisks match everything, including slashes.
 *
 * @param pattern pattern with wildcards
 * @param str string to match against
 * @returns true on match, false otherwise
 */
function globMatch(pattern: string, str: string): boolean {
  /* Position in the input string */
  let strPos = 0;
  /* Position in the pattern */
  let patPos = 0;
  /* Backtrack position in string */
  let strBt = -1;
  /* Backtrack position in pattern */
  let patBt = -1;
  for (; ;) {
    if (pattern[patPos] === "*") {
      strBt = strPos;
      patBt = patPos++;
    } else if (patPos === pattern.length && strPos === str.length) {
      return true;
    } else if (pattern[patPos] === str[strPos]) {
      strPos++;
      patPos++;
    } else {
      if (patBt < 0) {
        return false;
      }
      strPos = strBt + 1;
      if (strPos >= str.length) {
        return false;
      }
      patPos = patBt;
    }
  }
}
function normalizeInlineFilename(parentFile: string, f: string): string {
  if (f[0] === "/") {
    return f;
  }
  const resolvedParentDir = nodejs_path.dirname(
    nodejs_fs.realpathSync(parentFile),
  );
  return nodejs_path.join(resolvedParentDir, f);
}
/**
 * Crude implementation of the which(1) shell command.
 *
 * Tries to locate the location of an executable based on the
 * "PATH" environment variable.
 */
function which(name: string): string | undefined {
  const paths = process.env["PATH"]?.split(":");
  if (!paths) {
    return undefined;
  }
  for (const path of paths) {
    const filename = nodejs_path.join(path, name);
    if (nodejs_fs.existsSync(filename)) {
      return filename;
    }
  }
  return undefined;
}
export class Configuration {
  private sectionMap: SectionMap = {};
  private hintEntrypoint: string | undefined;
  private loadedFiles: LoadedFile[] = [];
  private nestLevel = 0;
  private loadFromFilename(
    filename: string,
    isDefaultSource: boolean,
    opts: LoadOptions = {},
  ): void {
    filename = expandPath(filename);
    const checkCycle = () => {
      let level = this.nestLevel;
      const fns = [...this.loadedFiles].reverse();
      for (const lf of fns) {
        if (lf.level >= level) {
          continue;
        }
        level = lf.level;
        if (lf.filename === filename) {
          throw Error(`cyclic inline ${lf.filename} -> ${filename}`);
        }
      }
    };
    checkCycle();
    const s = nodejs_fs.readFileSync(filename, "utf-8");
    this.loadedFiles.push({
      filename: filename,
      level: this.nestLevel,
    });
    const oldNestLevel = this.nestLevel;
    this.nestLevel += 1;
    try {
      this.internalLoadFromString(s, isDefaultSource, {
        ...opts,
        filename: filename,
      });
    } finally {
      this.nestLevel = oldNestLevel;
    }
  }
  private loadGlob(
    parentFilename: string,
    isDefaultSource: boolean,
    fileglob: string,
  ): void {
    const resolvedParent = nodejs_fs.realpathSync(parentFilename);
    const parentDir = nodejs_path.dirname(resolvedParent);
    let fullFileglob: string;
    if (fileglob.startsWith("/")) {
      fullFileglob = fileglob;
    } else {
      fullFileglob = nodejs_path.join(parentDir, fileglob);
    }
    fullFileglob = expandPath(fullFileglob);
    const head = nodejs_path.dirname(fullFileglob);
    const tail = nodejs_path.basename(fullFileglob);
    const files = nodejs_fs.readdirSync(head);
    for (const f of files) {
      if (globMatch(tail, f)) {
        const fullPath = nodejs_path.join(head, f);
        this.loadFromFilename(fullPath, isDefaultSource);
      }
    }
  }
  private loadSecret(
    sectionName: string,
    filename: string,
    isDefaultSource: boolean,
  ): void {
    const sec = this.provideSection(sectionName);
    sec.secretFilename = filename;
    const otherCfg = new Configuration();
    try {
      nodejs_fs.accessSync(filename, nodejs_fs.constants.R_OK);
    } catch (err) {
      sec.inaccessible = true;
      return;
    }
    otherCfg.loadFromFilename(filename, isDefaultSource, {
      banDirectives: true,
    });
    const otherSec = otherCfg.provideSection(sectionName);
    for (const opt of Object.keys(otherSec.entries)) {
      this.setString(sectionName, opt, otherSec.entries[opt].value);
    }
  }
  private internalLoadFromString(
    s: string,
    isDefaultSource: boolean,
    opts: LoadOptions = {},
  ): void {
    let lineNo = 0;
    const fn = opts.filename ?? "";
    const reComment = /^\s*#.*$/;
    const reSection = /^\s*\[\s*([^\]]*)\s*\]\s*$/;
    const reParam = /^\s*([^=]+?)\s*=\s*(.*?)\s*$/;
    const reDirective = /^\s*@([a-zA-Z-_]+)@\s*(.*?)\s*$/;
    const reEmptyLine = /^\s*$/;
    let currentSection: string | undefined = undefined;
    const lines = s.split("\n");
    for (const line of lines) {
      lineNo++;
      if (reEmptyLine.test(line)) {
        continue;
      }
      if (reComment.test(line)) {
        continue;
      }
      const directiveMatch = line.match(reDirective);
      if (directiveMatch) {
        if (opts.banDirectives) {
          throw Error(
            `invalid configuration, directive in ${fn}:${lineNo} forbidden`,
          );
        }
        const directive = directiveMatch[1].toLowerCase();
        switch (directive) {
          case "inline": {
            if (!opts.filename) {
              throw Error(
                `invalid configuration, @inline-matching@ directive in ${fn}:${lineNo} can only be used from a file`,
              );
            }
            const arg = directiveMatch[2].trim();
            this.loadFromFilename(
              normalizeInlineFilename(opts.filename, arg),
              isDefaultSource,
            );
            break;
          }
          case "inline-secret": {
            if (!opts.filename) {
              throw Error(
                `invalid configuration, @inline-matching@ directive in ${fn}:${lineNo} can only be used from a file`,
              );
            }
            const arg = directiveMatch[2].trim();
            const sp = arg.split(" ").map((x) => x.trim());
            if (sp.length != 2) {
              throw Error(
                `invalid configuration, @inline-secret@ directive in ${fn}:${lineNo} requires two arguments`,
              );
            }
            const secretFilename = normalizeInlineFilename(
              opts.filename,
              sp[1],
            );
            this.loadSecret(sp[0], secretFilename, isDefaultSource);
            break;
          }
          case "inline-matching": {
            const arg = directiveMatch[2].trim();
            if (!opts.filename) {
              throw Error(
                `invalid configuration, @inline-matching@ directive in ${fn}:${lineNo} can only be used from a file`,
              );
            }
            this.loadGlob(opts.filename, isDefaultSource, arg);
            break;
          }
          default:
            throw Error(
              `invalid configuration, unsupported directive in ${fn}:${lineNo}`,
            );
        }
        continue;
      }
      const secMatch = line.match(reSection);
      if (secMatch) {
        currentSection = secMatch[1];
        continue;
      }
      if (currentSection === undefined) {
        throw Error(
          `invalid configuration, expected section header in ${fn}:${lineNo}`,
        );
      }
      currentSection = currentSection.toUpperCase();
      const paramMatch = line.match(reParam);
      if (paramMatch) {
        const optName = paramMatch[1].toUpperCase();
        let val = paramMatch[2];
        if (val.startsWith('"') && val.endsWith('"')) {
          val = val.slice(1, val.length - 1);
        }
        const sec = this.provideSection(currentSection);
        sec.entries[optName] = {
          value: val,
          sourceFile: opts.filename ?? "",
          sourceLine: lineNo,
          origin: isDefaultSource
            ? EntryOrigin.DefaultFile
            : EntryOrigin.Loaded,
        };
        continue;
      }
      throw Error(
        `invalid configuration, expected section header, option assignment or directive in ${fn}:${lineNo}`,
      );
    }
  }
  loadFromString(s: string, opts: LoadOptions = {}): void {
    return this.internalLoadFromString(s, false, opts);
  }
  private provideSection(section: string): Section {
    const secNorm = section.toUpperCase();
    if (this.sectionMap[secNorm]) {
      return this.sectionMap[secNorm];
    }
    const newSec: Section = {
      entries: {},
      inaccessible: false,
    };
    this.sectionMap[secNorm] = newSec;
    return newSec;
  }
  private findEntry(section: string, option: string): Entry | undefined {
    const secNorm = section.toUpperCase();
    const optNorm = option.toUpperCase();
    return this.sectionMap[secNorm]?.entries[optNorm];
  }
  setString(section: string, option: string, value: string): void {
    const sec = this.provideSection(section);
    sec.entries[option.toUpperCase()] = {
      value,
      sourceLine: 0,
      sourceFile: "",
      origin: EntryOrigin.Changed,
    };
  }
  /**
   * Set a string value to a value from default locations.
   */
  private setStringSystemDefault(
    section: string,
    option: string,
    value: string,
  ): void {
    const sec = this.provideSection(section);
    sec.entries[option.toUpperCase()] = {
      value,
      sourceLine: 0,
      sourceFile: "",
      origin: EntryOrigin.DefaultSystem,
    };
  }
  /**
   * Get upper-cased section names.
   */
  getSectionNames(): string[] {
    return Object.keys(this.sectionMap).map((x) => x.toUpperCase());
  }
  getString(section: string, option: string): ConfigValue {
    const secNorm = section.toUpperCase();
    const optNorm = option.toUpperCase();
    const val = this.findEntry(secNorm, optNorm)?.value;
    return new ConfigValue(secNorm, optNorm, val, (x) => x);
  }
  getPath(section: string, option: string): ConfigValue {
    const secNorm = section.toUpperCase();
    const optNorm = option.toUpperCase();
    const val = this.findEntry(secNorm, optNorm)?.value;
    return new ConfigValue(secNorm, optNorm, val, (x) =>
      pathsub(x, (v, d) => this.lookupVariable(v, d + 1)),
    );
  }
  getYesNo(section: string, option: string): ConfigValue {
    const secNorm = section.toUpperCase();
    const optNorm = option.toUpperCase();
    const val = this.findEntry(secNorm, optNorm)?.value;
    const convert = (x: string): boolean => {
      x = x.toLowerCase();
      if (x === "yes") {
        return true;
      } else if (x === "no") {
        return false;
      }
      throw Error(
        `invalid config value for [${secNorm}]/${optNorm}, expected yes/no`,
      );
    };
    return new ConfigValue(secNorm, optNorm, val, convert);
  }
  getNumber(section: string, option: string): ConfigValue {
    const secNorm = section.toUpperCase();
    const optNorm = option.toUpperCase();
    const val = this.findEntry(secNorm, optNorm)?.value;
    const convert = (x: string): number => {
      try {
        return Number.parseInt(x, 10);
      } catch (e) {
        throw Error(
          `invalid config value for [${secNorm}]/${optNorm}, expected number`,
        );
      }
    };
    return new ConfigValue(secNorm, optNorm, val, convert);
  }
  lookupVariable(x: string, depth: number = 0): string | undefined {
    // We loop up options in PATHS in upper case, as option names
    // are case insensitive
    const val = this.findEntry("PATHS", x)?.value;
    if (val !== undefined) {
      return pathsub(val, (v, d) => this.lookupVariable(v, d), depth);
    }
    // Environment variables can be case sensitive, respect that.
    const envVal = process.env[x];
    if (envVal !== undefined) {
      return envVal;
    }
    logger.warn(`unable to resolve variable '${x}'`);
    return;
  }
  getAmount(section: string, option: string): ConfigValue {
    const val = this.findEntry(section, option)?.value;
    return new ConfigValue(section, option, val, (x) =>
      Amounts.parseOrThrow(x),
    );
  }
  loadDefaultsFromDir(dirname: string): void {
    const files = nodejs_fs.readdirSync(dirname);
    for (const f of files) {
      const fn = nodejs_path.join(dirname, f);
      this.loadFromFilename(fn, true);
    }
  }
  private loadDefaults(): void {
    let baseConfigDir = process.env["TALER_BASE_CONFIG"];
    if (!baseConfigDir) {
      /* Try to locate the configuration based on the location
       * of the taler-config binary. */
      const path = which("taler-config");
      if (path) {
        baseConfigDir = nodejs_fs.realpathSync(
          nodejs_path.dirname(path) + "/../share/taler/config.d",
        );
      }
    }
    if (!baseConfigDir) {
      baseConfigDir = "/usr/share/taler/config.d";
    }
    let installPrefix = process.env["TALER_PREFIX"];
    if (!installPrefix) {
      /* Try to locate install path based on the location
       * of the taler-config binary. */
      const path = which("taler-config");
      if (path) {
        installPrefix = nodejs_fs.realpathSync(
          nodejs_path.dirname(path) + "/..",
        );
      }
    }
    if (!installPrefix) {
      installPrefix = "/usr";
    }
    this.setStringSystemDefault(
      "PATHS",
      "LIBEXECDIR",
      `${installPrefix}/taler/libexec/`,
    );
    this.setStringSystemDefault(
      "PATHS",
      "DOCDIR",
      `${installPrefix}/share/doc/taler/`,
    );
    this.setStringSystemDefault(
      "PATHS",
      "ICONDIR",
      `${installPrefix}/share/icons/`,
    );
    this.setStringSystemDefault(
      "PATHS",
      "LOCALEDIR",
      `${installPrefix}/share/locale/`,
    );
    this.setStringSystemDefault("PATHS", "PREFIX", `${installPrefix}/`);
    this.setStringSystemDefault("PATHS", "BINDIR", `${installPrefix}/bin`);
    this.setStringSystemDefault(
      "PATHS",
      "LIBDIR",
      `${installPrefix}/lib/taler/`,
    );
    this.setStringSystemDefault(
      "PATHS",
      "DATADIR",
      `${installPrefix}/share/taler/`,
    );
    this.loadDefaultsFromDir(baseConfigDir);
  }
  getDefaultConfigFilename(): string | undefined {
    const xdg = process.env["XDG_CONFIG_HOME"];
    const home = process.env["HOME"];
    let fn: string | undefined;
    if (xdg) {
      fn = nodejs_path.join(xdg, "taler.conf");
    } else if (home) {
      fn = nodejs_path.join(home, ".config/taler.conf");
    }
    if (fn && nodejs_fs.existsSync(fn)) {
      return fn;
    }
    const etc1 = "/etc/taler.conf";
    if (nodejs_fs.existsSync(etc1)) {
      return etc1;
    }
    const etc2 = "/etc/taler/taler.conf";
    if (nodejs_fs.existsSync(etc2)) {
      return etc2;
    }
    return undefined;
  }
  static load(filename?: string): Configuration {
    const cfg = new Configuration();
    cfg.loadDefaults();
    if (filename) {
      cfg.loadFromFilename(filename, false);
    } else {
      const fn = cfg.getDefaultConfigFilename();
      if (fn) {
        // It's the default filename for the main config file,
        // but we don't consider the values default values.
        cfg.loadFromFilename(fn, false);
      }
    }
    cfg.hintEntrypoint = filename;
    return cfg;
  }
  stringify(opts: StringifyOptions = {}): string {
    let s = "";
    if (opts.diagnostics) {
      s += "# Configuration file diagnostics\n";
      s += "#\n";
      s += `# Entry point: ${this.hintEntrypoint ?? ""}\n`;
      s += "#\n";
      s += "# Loaded files:\n";
      for (const f of this.loadedFiles) {
        s += `# ${f.filename}\n`;
      }
      s += "#\n\n";
    }
    for (const sectionName of Object.keys(this.sectionMap)) {
      const sec = this.sectionMap[sectionName];
      let headerWritten = false;
      for (const optionName of Object.keys(sec.entries)) {
        const entry = this.sectionMap[sectionName].entries[optionName];
        if (
          opts.excludeDefaults &&
          (entry.origin === EntryOrigin.DefaultSystem ||
            entry.origin === EntryOrigin.DefaultFile)
        ) {
          continue;
        }
        if (!headerWritten) {
          if (opts.diagnostics && sec.secretFilename) {
            s += `# Secret section from ${sec.secretFilename}\n`;
            s += `# Secret accessible: ${!sec.inaccessible}\n`;
          }
          s += `[${sectionName}]\n`;
          headerWritten = true;
        }
        if (entry !== undefined) {
          if (opts.diagnostics) {
            switch (entry.origin) {
              case EntryOrigin.DefaultFile:
              case EntryOrigin.Changed:
              case EntryOrigin.Loaded:
                s += `# ${entry.sourceFile}:${entry.sourceLine}\n`;
                break;
              case EntryOrigin.DefaultSystem:
                s += `# (system/installation default)\n`;
                break;
            }
          }
          s += `${optionName} = ${entry.value}\n`;
        }
      }
      if (headerWritten) {
        s += "\n";
      }
    }
    return s;
  }
  write(filename: string, opts: { excludeDefaults?: boolean } = {}): void {
    nodejs_fs.writeFileSync(
      filename,
      this.stringify({ excludeDefaults: opts.excludeDefaults }),
    );
  }
}