/*
 This file is part of GNU Taler
 (C) 2020 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";
const nodejs_fs = (function () {
  let fs: typeof import("fs");
  return function () {
    if (!fs) {
      /**
       * need to use an expression when doing a require if we want
       * webpack not to find out about the requirement
       */
      const _r = "require";
      fs = module[_r]("fs");
    }
    return fs;
  };
})();
const nodejs_path = (function () {
  let path: typeof import("path");
  return function () {
    if (!path) {
      /**
       * need to use an expression when doing a require if we want
       * webpack not to find out about the requirement
       */
      const _r = "require";
      path = module[_r]("path");
    }
    return path;
  };
})();
const nodejs_os = (function () {
  let os: typeof import("os");
  return function () {
    if (!os) {
      /**
       * need to use an expression when doing a require if we want
       * webpack not to find out about the requirement
       */
      const _r = "require";
      os = module[_r]("os");
    }
    return os;
  };
})();
export class ConfigError extends Error {
  constructor(message: string) {
    super();
    Object.setPrototypeOf(this, ConfigError.prototype);
    this.name = "ConfigError";
    this.message = message;
  }
}
interface Entry {
  value: string;
  sourceLine: number;
  sourceFile: string;
}
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,
    public 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;
  }
}
/**
 * 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,
  depth = 0,
): string {
  if (depth >= 10) {
    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.substr(0, start) + r + s.substr(p + 1);
            l = start + r.length;
            continue;
          } else if (defaultValue !== undefined) {
            const resolvedDefault = pathsub(defaultValue, lookup, depth + 1);
            s = s.substr(0, start) + resolvedDefault + s.substr(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], depth + 1);
          if (r !== undefined) {
            s = s.substr(0, l) + r + s.substr(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;
}
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, 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.loadFromString(s, {
        ...opts,
        filename: filename,
      });
    } finally {
      this.nestLevel = oldNestLevel;
    }
  }
  private loadGlob(parentFilename: string, 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);
      }
    }
  }
  private loadSecret(sectionName: string, filename: string): 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, {
      banDirectives: true,
    });
    const otherSec = otherCfg.provideSection(sectionName);
    for (const opt of Object.keys(otherSec.entries)) {
      this.setString(sectionName, opt, otherSec.entries[opt].value);
    }
  }
  loadFromString(s: string, 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));
            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);
            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, 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,
        };
        continue;
      }
      throw Error(
        `invalid configuration, expected section header, option assignment or directive in ${fn}:${lineNo}`,
      );
    }
  }
  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: "",
    };
  }
  /**
   * 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;
    }
    return;
  }
  getAmount(section: string, option: string): ConfigValue {
    const val = this.findEntry(section, option)?.value;
    return new ConfigValue(section, option, val, (x) =>
      Amounts.parseOrThrow(x),
    );
  }
  loadFrom(dirname: string): void {
    const files = nodejs_fs().readdirSync(dirname);
    for (const f of files) {
      const fn = nodejs_path().join(dirname, f);
      this.loadFromFilename(fn);
    }
  }
  private loadDefaults(): void {
    let bc = process.env["TALER_BASE_CONFIG"];
    if (!bc) {
      /* Try to locate the configuration based on the location
       * of the taler-config binary. */
      const path = which("taler-config");
      if (path) {
        bc = nodejs_fs().realpathSync(
          nodejs_path().dirname(path) + "/../share/taler/config.d",
        );
      }
    }
    if (!bc) {
      bc = "/usr/share/taler/config.d";
    }
    this.loadFrom(bc);
  }
  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);
    } else {
      const fn = cfg.getDefaultConfigFilename();
      if (fn) {
        cfg.loadFromFilename(fn);
      }
    }
    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];
      if (opts.diagnostics && sec.secretFilename) {
        s += `# Secret section from ${sec.secretFilename}\n`;
        s += `# Secret accessible: ${!sec.inaccessible}\n`;
      }
      s += `[${sectionName}]\n`;
      for (const optionName of Object.keys(sec.entries)) {
        const entry = this.sectionMap[sectionName].entries[optionName];
        if (entry !== undefined) {
          if (opts.diagnostics) {
            s += `# ${entry.sourceFile}:${entry.sourceLine}\n`;
          }
          s += `${optionName} = ${entry.value}\n`;
        }
      }
      s += "\n";
    }
    return s;
  }
  write(filename: string): void {
    nodejs_fs().writeFileSync(filename, this.stringify());
  }
}