/*
 This file is part of GNU Taler
 (C) 2020-2025 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 <http://www.gnu.org/licenses/>
 */

import {
  ConfigSources,
  Configuration,
  DonauHttpClient,
} from "@gnu-taler/taler-util";
import * as fs from "node:fs";
import { CoinConfig } from "./denomStructures.js";
import { GlobalTestState, pingProc, ProcessWrapper, sh } from "./harness.js";

/**
 * Test harness for various DONAU, the
 * donation authority service.
 *
 * @author Florian Dold <dold@taler.net>
 */

export interface DonauConfig {
  name: string;
  currency: string;
  hostname?: string;
  roundUnit?: string;
  httpPort: number;
  database: string;
  overrideTestDir?: string;

  /** Financial domain that the donau operates under. */
  domain: string;

  /**
   * Extra environment variables to pass to the donau processes.
   */
  extraProcEnv?: Record<string, string>;
}

/**
 * Donau service handle.
 */
export class DonauService {
  private currentTimetravelOffsetMs: number | undefined;

  setTimetravel(tMs: number | undefined): void {
    if (this.isRunning()) {
      throw Error("can't set time travel while the donau is running");
    }
    this.currentTimetravelOffsetMs = tMs;
  }

  private get timetravelArg(): string | undefined {
    if (this.currentTimetravelOffsetMs != null) {
      // Convert to microseconds
      return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`;
    }
    return undefined;
  }

  /**
   * Return an empty array if no time travel is set,
   * and an array with the time travel command line argument
   * otherwise.
   */
  private get timetravelArgArr(): string[] {
    const tta = this.timetravelArg;
    if (tta) {
      return [tta];
    }
    return [];
  }

  get currency() {
    return this.donauConfig.currency;
  }

  changeConfig(f: (config: Configuration) => void) {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["donau"],
    );
    f(config);
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  static create(gc: GlobalTestState, e: DonauConfig) {
    const testDir = e.overrideTestDir ?? gc.testDir;
    const config = new Configuration();
    setDonauPaths(config, `${testDir}/talerhome-donau-${e.name}`, e.name);
    config.setString("donau", "currency", e.currency);
    config.setString(
      "donau",
      "currency_round_unit",
      e.roundUnit ?? `${e.currency}:0.01`,
    );
    const hostname = e.hostname ?? "localhost";
    config.setString("donau", "base_url", `http://${hostname}:${e.httpPort}/`);
    config.setString("donau", "serve", "tcp");
    config.setString("donau", "port", `${e.httpPort}`);
    config.setString("donau", "legal_domain", e.domain);
    config.setString("donau", "expire_legal_years", "5");

    config.setString("donaudb-postgres", "config", e.database);

    // Limit signing lookahead to make the test startup faster.

    config.setString("donau-secmod-cs", "lookahead_sign", "24 days");
    config.setString("donau-secmod-cs", "overlap_duration", "0");

    config.setString("donau-secmod-rsa", "lookahead_sign", "24 days");
    config.setString("donau-secmod-rsa", "overlap_duration", "0");

    config.setString("donau-secmod-eddsa", "lookahead_sign", "24 days");
    config.setString("donau-secmod-eddsa", "duration", "14 days");

    const cfgFilename = testDir + `/donau-${e.name}.conf`;
    config.writeTo(cfgFilename, { excludeDefaults: true });
    return new DonauService(gc, e, cfgFilename);
  }

  /**
   * Run a function that modifies the existing donau configuration.
   * The modified configuration will then be written to the
   * file system.
   */
  async modifyConfig(
    f: (config: Configuration) => Promise<void>,
  ): Promise<void> {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["donau"],
    );
    await f(config);
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  donauHttpProc: ProcessWrapper | undefined;

  helperCryptoRsaProc: ProcessWrapper | undefined;
  helperCryptoEddsaProc: ProcessWrapper | undefined;
  helperCryptoCsProc: ProcessWrapper | undefined;

  constructor(
    private globalState: GlobalTestState,
    private donauConfig: DonauConfig,
    private configFilename: string,
  ) {}

  get name() {
    return this.donauConfig.name;
  }

  get baseUrl() {
    const host = this.donauConfig.hostname ?? "localhost";
    return `http://${host}:${this.donauConfig.httpPort}/`;
  }

  isRunning(): boolean {
    return !!this.donauHttpProc;
  }

  async stop(): Promise<void> {
    const httpd = this.donauHttpProc;
    if (httpd) {
      httpd.proc.kill("SIGTERM");
      await httpd.wait();
      this.donauHttpProc = undefined;
    }
    const cryptoRsa = this.helperCryptoRsaProc;
    if (cryptoRsa) {
      cryptoRsa.proc.kill("SIGTERM");
      await cryptoRsa.wait();
      this.helperCryptoRsaProc = undefined;
    }
    const cryptoEddsa = this.helperCryptoEddsaProc;
    if (cryptoEddsa) {
      cryptoEddsa.proc.kill("SIGTERM");
      await cryptoEddsa.wait();
      this.helperCryptoRsaProc = undefined;
    }
    const cryptoCs = this.helperCryptoCsProc;
    if (cryptoCs) {
      cryptoCs.proc.kill("SIGTERM");
      await cryptoCs.wait();
      this.helperCryptoCsProc = undefined;
    }
  }

  async dbinit() {
    await sh(
      this.globalState,
      "donau-dbinit",
      `donau-dbinit -c "${this.configFilename}"`,
    );
  }

  addCoinConfigList(ccs: CoinConfig[]) {
    const config = Configuration.load(
      this.configFilename,
      ConfigSources["donau"],
    );
    ccs.forEach((cc) => setDonauCoin(config, cc));
    config.writeTo(this.configFilename, { excludeDefaults: true });
  }

  async start(
    opts: { skipDbinit?: boolean; skipKeyup?: boolean } = {},
  ): Promise<void> {
    if (this.isRunning()) {
      throw Error("donau is already running");
    }

    const skipDbinit = opts.skipDbinit ?? false;

    if (!skipDbinit) {
      await this.dbinit();
    }

    this.helperCryptoEddsaProc = this.globalState.spawnService(
      "donau-secmod-eddsa",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `donau-secmod-eddsa-${this.name}`,
    );

    this.helperCryptoCsProc = this.globalState.spawnService(
      "donau-secmod-cs",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `donau-secmod-cs-${this.name}`,
    );

    this.helperCryptoRsaProc = this.globalState.spawnService(
      "donau-secmod-rsa",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `donau-secmod-rsa-${this.name}`,
    );

    this.donauHttpProc = this.globalState.spawnService(
      "donau-httpd",
      ["-LDEBUG", "-c", this.configFilename, ...this.timetravelArgArr],
      `donau-httpd-${this.name}`,
      { ...process.env, ...(this.donauConfig.extraProcEnv ?? {}) },
    );

    await this.pingUntilAvailable();

    {
      const donauClient = new DonauHttpClient(this.baseUrl, {});
      // Would throw on incompatible version.
      const configResp = await donauClient.getConfig();
      this.globalState.assertTrue(configResp.type === "ok");
    }
  }

  async pingUntilAvailable(): Promise<void> {
    // We request /management/keys, since /keys can block
    // when we didn't do the key setup yet.
    const url = `http://localhost:${this.donauConfig.httpPort}/config`;
    await pingProc(this.donauHttpProc, url, `donau (${this.name})`);
  }

  async purgeSecmodKeys(): Promise<void> {
    const cfg = Configuration.load(this.configFilename, ConfigSources["donau"]);
    const rsaKeydir = cfg.getPath("donau-secmod-rsa", "KEY_DIR").required();
    // Be *VERY* careful when changing this, or you will accidentally delete user data.
    await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/DOCO_*`);
  }
}

/**
 * @param name additional component name, needed when launching multiple instances of the same component
 */
function setDonauPaths(config: Configuration, home: string, name?: string) {
  config.setString("paths", "donau_home", home);
  // We need to make sure that the path of taler_runtime_dir isn't too long,
  // as it contains unix domain sockets (108 character limit).
  const extraName = name != null ? `${name}-` : "";
  const runDir = fs.mkdtempSync(`/tmp/donau-test-${extraName}`);
  config.setString("paths", "donau_runtime_dir", runDir);
  config.setString(
    "paths",
    "donau_data_home",
    "$DONAU_HOME/.local/share/donau/",
  );
  config.setString("paths", "donau_config_home", "$DONAU_HOME/.config/donau/");
  config.setString("paths", "donau_cache_home", "$DONAU_HOME/.config/donau/");
}

function setDonauCoin(config: Configuration, c: CoinConfig) {
  const s = `doco_${c.name}`;
  config.setString(s, "value", c.value);
  config.setString(s, "duration_withdraw", " 1 year");
  config.setString(s, "anchor_round", " 1 year");
  config.setString(s, "duration_spend", "2 years");
  config.setString(s, "duration_legal", "3 years");
  config.setString(s, "fee_deposit", c.feeDeposit);
  config.setString(s, "fee_withdraw", c.feeWithdraw);
  config.setString(s, "fee_refresh", c.feeRefresh);
  config.setString(s, "fee_refund", c.feeRefund);
  if (c.cipher === "RSA") {
    config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
    config.setString(s, "cipher", "RSA");
  } else if (c.cipher === "CS") {
    config.setString(s, "cipher", "CS");
  } else {
    throw new Error();
  }
}
