/*
 This file is part of GNU Taler
 (C) 2021 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/>
 */

/**
 * The deployment linter implements checks for a deployment
 * of the GNU Taler exchange.  It is meant to help sysadmins
 * when setting up an exchange.
 *
 * The linter does checks in the configuration and uses
 * various tools of the exchange in test mode (-t).
 *
 * To be able to run the tools as the right user, the linter should be
 * run as root.
 *
 * @author Florian Dold <dold@taler.net>
 */

/**
 * Imports.
 */
import {
  AbsoluteTime,
  canonicalizeBaseUrl,
  codecForExchangeKeysResponse,
  codecForKeysManagementResponse,
  Configuration,
  decodeCrock,
  parsePaytoUri,
  succeedOrThrow,
  TalerExchangeHttpClient,
  TalerProtocolTimestamp,
} from "@gnu-taler/taler-util";
import {
  createPlatformHttpLib,
  readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http";
import { spawn } from "child_process";
import { URL } from "node:url";
import { delayMs } from "./harness/harness.js";

interface BasicConf {
  mainCurrency: string;
}

interface PubkeyConf {
  masterPublicKey: string;
}

const httpLib = createPlatformHttpLib({
  enableThrottling: false,
});

interface ShellResult {
  stdout: string;
  stderr: string;
  status: number;
}

interface LintContext {
  /**
   * Be more verbose.
   */
  verbose: boolean;

  /**
   * Always continue even after errors.
   */
  cont: boolean;

  cfg: Configuration;

  numErr: number;
}

/**
 * Run a shell command, return stdout.
 */
export async function sh(
  context: LintContext,
  command: string,
  env: { [index: string]: string | undefined } = process.env,
): Promise<ShellResult> {
  if (context.verbose) {
    console.log("executing command:", command);
  }
  return new Promise((resolve, reject) => {
    const stdoutChunks: Buffer[] = [];
    const stderrChunks: Buffer[] = [];
    const proc = spawn(command, {
      stdio: ["inherit", "pipe", "pipe"],
      shell: true,
      env: env,
    });
    proc.stdout.on("data", (x) => {
      if (x instanceof Buffer) {
        stdoutChunks.push(x);
      } else {
        throw Error("unexpected data chunk type");
      }
    });
    proc.stderr.on("data", (x) => {
      if (x instanceof Buffer) {
        stderrChunks.push(x);
      } else {
        throw Error("unexpected data chunk type");
      }
    });
    proc.on("exit", (code, signal) => {
      if (code != 0 && context.verbose) {
        console.log(`child process exited (${code} / ${signal})`);
      }
      const bOut = Buffer.concat(stdoutChunks).toString("utf-8");
      const bErr = Buffer.concat(stderrChunks).toString("utf-8");
      resolve({
        status: code ?? -1,
        stderr: bErr,
        stdout: bOut,
      });
    });
    proc.on("error", () => {
      reject(Error("Child process had error"));
    });
  });
}

function checkBasicConf(context: LintContext): BasicConf {
  const cfg = context.cfg;
  const currencyEntry = cfg.getString("taler", "currency");
  let mainCurrency: string | undefined;

  if (!currencyEntry.isDefined()) {
    context.numErr++;
    console.log("error: currency not defined in section TALER option CURRENCY");
    console.log("Aborting further checks.");
    process.exit(1);
  } else {
    mainCurrency = currencyEntry.required().toUpperCase();
  }

  if (mainCurrency === "KUDOS") {
    console.log(
      "warning: section TALER option CURRENCY contains toy currency value KUDOS",
    );
  }

  const roundUnit = cfg.getAmount("taler", "currency_round_unit");
  const ru = roundUnit.required();
  if (ru.currency.toLowerCase() != mainCurrency.toLowerCase()) {
    context.numErr++;
    console.log(
      "error: [TALER]/CURRENCY_ROUND_UNIT: currency does not match main currency",
    );
  }
  return { mainCurrency };
}

function checkCoinConfig(context: LintContext, basic: BasicConf): void {
  const cfg = context.cfg;
  const coinPrefix1 = "COIN_";
  const coinPrefix2 = "COIN-";
  let numCoins = 0;

  for (const secName of cfg.getSectionNames()) {
    if (!(secName.startsWith(coinPrefix1) || secName.startsWith(coinPrefix2))) {
      continue;
    }
    numCoins++;

    // FIXME: check that section is well-formed
  }

  if (numCoins == 0) {
    context.numErr++;
    console.log(
      "error: no coin denomination configured, please configure [coin-*] sections",
    );
  }
}

async function checkWireConfig(context: LintContext): Promise<void> {
  const cfg = context.cfg;
  const accountPrefix = "EXCHANGE-ACCOUNT-";
  const accountCredentialsPrefix = "EXCHANGE-ACCOUNTCREDENTIALS-";

  let accounts = new Set<string>();
  let credentials = new Set<string>();

  for (const secName of cfg.getSectionNames()) {
    if (secName.startsWith(accountPrefix)) {
      accounts.add(secName.slice(accountPrefix.length));
      // FIXME: check settings
    }

    if (secName.startsWith(accountCredentialsPrefix)) {
      credentials.add(secName.slice(accountCredentialsPrefix.length));
      // FIXME: check settings
    }
  }

  if (accounts.size === 0) {
    context.numErr++;
    console.log(
      "error: No accounts configured (no sections EXCHANGE-ACCOUNT-*).",
    );
    if (!context.cont) {
      console.log("Aborting further checks.");
      process.exit(1);
    }
  }

  for (const acc of accounts) {
    if (!credentials.has(acc)) {
      console.log(
        `warning: no credentials configured for exchange-account-${acc}`,
      );
    }
  }

  for (const acc of accounts) {
    // test credit history
    {
      const res = await sh(
        context,
        "su -l --shell /bin/sh " +
          `-c 'taler-exchange-wire-gateway-client -s exchange-accountcredentials-${acc} --credit-history' ` +
          "taler-exchange-wire",
      );
      if (res.status != 0) {
        context.numErr++;
        console.log(res.stdout);
        console.log(res.stderr);
        console.log(
          "error: Could not run taler-exchange-wire-gateway-client. Please review logs above.",
        );
        if (!context.cont) {
          console.log("Aborting further checks.");
          process.exit(1);
        }
      }
    }
  }

  // TWG client
  {
    const res = await sh(
      context,
      `su -l --shell /bin/sh -c 'taler-exchange-wirewatch -t' taler-exchange-wire`,
    );
    if (res.status != 0) {
      context.numErr++;
      console.log(res.stdout);
      console.log(res.stderr);
      console.log("error: Could not run wirewatch. Please review logs above.");
      if (!context.cont) {
        console.log("Aborting further checks.");
        process.exit(1);
      }
    }
  }

  // Wirewatch
  {
    const res = await sh(
      context,
      `su -l --shell /bin/sh -c 'taler-exchange-wirewatch -t' taler-exchange-wire`,
    );
    if (res.status != 0) {
      context.numErr++;
      console.log(res.stdout);
      console.log(res.stderr);
      console.log("error: Could not run wirewatch. Please review logs above.");
      if (!context.cont) {
        console.log("Aborting further checks.");
        process.exit(1);
      }
    }
  }

  // Closer
  {
    const res = await sh(
      context,
      `su -l --shell /bin/sh -c 'taler-exchange-closer -t' taler-exchange-closer`,
    );
    if (res.status != 0) {
      context.numErr++;
      console.log(res.stdout);
      console.log(res.stderr);
      console.log("error: Could not run closer. Please review logs above.");
      if (!context.cont) {
        console.log("Aborting further checks.");
        process.exit(1);
      }
    }
  }
}

async function checkAggregatorConfig(context: LintContext) {
  const res = await sh(
    context,
    "su -l --shell /bin/sh -c 'taler-exchange-aggregator -t' taler-exchange-aggregator",
  );
  if (res.status != 0) {
    context.numErr++;
    console.log(res.stdout);
    console.log(res.stderr);
    console.log("error: Could not run aggregator. Please review logs above.");
    if (!context.cont) {
      console.log("Aborting further checks.");
      process.exit(1);
    }
  }
}

async function checkCloserConfig(context: LintContext) {
  const res = await sh(
    context,
    `su -l --shell /bin/sh -c 'taler-exchange-closer -t' taler-exchange-closer`,
  );
  if (res.status != 0) {
    context.numErr++;
    console.log(res.stdout);
    console.log(res.stderr);
    console.log("error: Could not run closer. Please review logs above.");
    if (!context.cont) {
      console.log("Aborting further checks.");
      process.exit(1);
    }
  }
}

function checkMasterPublicKeyConfig(context: LintContext): PubkeyConf {
  const cfg = context.cfg;
  const pub = cfg.getString("exchange", "master_public_key");

  const pubDecoded = decodeCrock(pub.required());

  if (pubDecoded.length != 32) {
    context.numErr++;
    console.log("error: invalid master public key");
    if (!context.cont) {
      console.log("Aborting further checks.");
      process.exit(1);
    }
  }

  return {
    masterPublicKey: pub.required(),
  };
}

export async function checkExchangeHttpd(
  context: LintContext,
  pubConf: PubkeyConf,
): Promise<void> {
  const cfg = context.cfg;
  const baseUrlEntry = cfg.getString("exchange", "base_url");

  if (!baseUrlEntry.isDefined) {
    context.numErr++;
    console.log(
      "error: configuration needs to specify section EXCHANGE option BASE_URL",
    );
    if (!context.cont) {
      console.log("Aborting further checks.");
      process.exit(1);
    }
  }

  const baseUrl = baseUrlEntry.required();

  if (!baseUrl.startsWith("http")) {
    context.numErr++;
    console.log(
      "error: section EXCHANGE option BASE_URL needs to be an http or https URL",
    );
    if (!context.cont) {
      console.log("Aborting further checks.");
      process.exit(1);
    }
  }

  if (!baseUrl.endsWith("/")) {
    context.numErr++;
    console.log(
      "error: section EXCHANGE option BASE_URL needs to end with a slash",
    );
    if (!context.cont) {
      console.log("Aborting further checks.");
      process.exit(1);
    }
  }

  if (!baseUrl.startsWith("https://")) {
    console.log(
      "warning: section EXCHANGE option BASE_URL: it is recommended to serve the exchange via HTTPS",
    );
  }

  {
    const mgmtUrl = new URL("management/keys", baseUrl);
    const resp = await httpLib.fetch(mgmtUrl.href);

    const futureKeys = await readSuccessResponseJsonOrThrow(
      resp,
      codecForKeysManagementResponse(),
    );

    if (futureKeys.future_denoms.length > 0) {
      console.log(
        `warning: exchange has denomination keys that need to be signed by the offline signing procedure`,
      );
    }

    if (futureKeys.future_signkeys.length > 0) {
      console.log(
        `warning: exchange has signing keys that need to be signed by the offline signing procedure`,
      );
    }
  }

  // Check if we can use /keys already
  {
    const keysUrl = new URL("keys", baseUrl);

    const resp = await Promise.race([
      httpLib.fetch(keysUrl.href),
      delayMs(2000),
    ]);

    if (!resp) {
      context.numErr++;
      console.log(
        "error: request to /keys timed out. " +
          "Make sure to sign and upload denomination and signing keys " +
          "with taler-exchange-offline.",
      );
      if (!context.cont) {
        console.log("Aborting further checks.");
        process.exit(1);
      }
    } else {
      const keys = await readSuccessResponseJsonOrThrow(
        resp,
        codecForExchangeKeysResponse(),
      );

      if (keys.master_public_key !== pubConf.masterPublicKey) {
        context.numErr++;
        console.log(
          "error: master public key of exchange does not match public key of live exchange",
        );
        if (!context.cont) {
          console.log("Aborting further checks.");
          process.exit(1);
        }
      }
    }
  }

  // Check /wire
  {
    const keysUrl = new URL("wire", baseUrl);

    const resp = await Promise.race([
      httpLib.fetch(keysUrl.href),
      delayMs(2000),
    ]);

    if (!resp) {
      context.numErr++;
      console.log(
        "error: request to /wire timed out. " +
          "Make sure to sign and upload accounts and wire fees " +
          "using the taler-exchange-offline tool.",
      );
      if (!context.cont) {
        console.log("Aborting further checks.");
        process.exit(1);
      }
    } else {
      if (resp.status !== 200) {
        console.log(
          "error:  Can't access exchange /wire.  Please check " +
            "the logs of taler-exchange-httpd for further information.",
        );
      }
    }
  }
}

/**
 * Do some basic checks in the configuration of a Taler deployment.
 */
export async function lintExchangeDeployment(
  verbose: boolean,
  cont: boolean,
): Promise<void> {
  if (process.getuid!() != 0) {
    console.log(
      "warning: the exchange deployment linter is designed to be run as root",
    );
  }

  const cfg = Configuration.load();

  const context: LintContext = {
    cont,
    verbose,
    cfg,
    numErr: 0,
  };

  const basic = checkBasicConf(context);

  checkCoinConfig(context, basic);

  await checkWireConfig(context);

  await checkAggregatorConfig(context);

  await checkCloserConfig(context);

  const pubConf = checkMasterPublicKeyConfig(context);

  await checkExchangeHttpd(context, pubConf);

  if (context.numErr == 0) {
    console.log("Linting completed without errors.");
    process.exit(0);
  } else {
    console.log(`Linting completed with ${context.numErr} errors.`);
    process.exit(1);
  }
}

function isCurrentlyValid(
  start: TalerProtocolTimestamp,
  end: TalerProtocolTimestamp,
): boolean {
  if (!AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(start))) {
    return false;
  }
  if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(end))) {
    return false;
  }
  return true;
}

class LintReporter {
  numErrors = 0;
  reportError(s: string): void {
    this.numErrors++;
    console.log(`ERROR: ${s}`);
  }
  reportWarning(s: string): void {
    console.log(`WARNING: ${s}`);
  }
  reportInfo(s: string): void {
    console.log(`INFO: ${s}`);
  }
}

export async function lintExchangeUrl(exchangeBaseUrl: string): Promise<void> {
  const canonUrl = canonicalizeBaseUrl(exchangeBaseUrl);
  if (exchangeBaseUrl != canonUrl) {
    console.error(`invalid or non-canonical base url: ${exchangeBaseUrl}`);
    process.exit(1);
  }
  const exchangeClient = new TalerExchangeHttpClient(canonUrl, {});
  const configResp = succeedOrThrow(await exchangeClient.getConfig());
  const currency = configResp.currency;
  const keysResp = succeedOrThrow(await exchangeClient.getKeys());

  const reporter = new LintReporter();

  if (keysResp.currency != currency) {
    reporter.reportError("currency mismatch between /config and /keys");
  }

  if (keysResp.signkeys.length == 0) {
    reporter.reportError("exchange has no signing keys");
  }
  // Check for valid signing key
  {
    let haveValidSk = false;
    for (const sk of keysResp.signkeys) {
      if (isCurrentlyValid(sk.stamp_start, sk.stamp_end)) {
        haveValidSk = true;
        break;
      }
    }
    if (!haveValidSk) {
      reporter.reportError("no signing key is valid");
    }
  }
  let numDenomWithdraw = 0;
  let numDenomDeposit = 0;
  let numDenomLost = 0;
  // Check denominations
  {
    for (const denomGroup of keysResp.denominations) {
      for (const denom of denomGroup.denoms) {
        if (denom.lost) {
          numDenomLost++;
        }
        if (
          !denom.lost &&
          isCurrentlyValid(denom.stamp_start, denom.stamp_expire_withdraw)
        ) {
          numDenomWithdraw++;
        }
        if (isCurrentlyValid(denom.stamp_start, denom.stamp_expire_deposit)) {
          numDenomDeposit++;
        }
      }
    }
    if (numDenomWithdraw == 0) {
      reporter.reportWarning("no valid denomination available for withdrawal");
    }
    if (numDenomDeposit == 0) {
      reporter.reportWarning("no valid denomination available for deposit");
    }
    if (numDenomLost > 0) {
      reporter.reportWarning(`have lost denominations (${numDenomLost})`);
    }
  }
  {
    let haveGlobalFee = false;
    for (const gf of keysResp.global_fees) {
      if (isCurrentlyValid(gf.start_date, gf.end_date)) {
        haveGlobalFee = true;
        break;
      }
    }
    if (!haveGlobalFee) {
      reporter.reportError("no valid global fees for current time");
    }
  }
  if (keysResp.accounts.length === 0) {
    reporter.reportError("no wire accounts configured");
  }
  let wireTypesSet = new Set<string>();
  for (const acc of keysResp.accounts) {
    const payto = parsePaytoUri(acc.payto_uri);
    if (!payto) {
      reporter.reportError(`invalid account payto URI: ${acc.payto_uri}`);
      continue;
    }
    wireTypesSet.add(payto?.targetType);
  }
  for (const wireType of wireTypesSet) {
    let haveAccountFees = false;
    for (const wf of keysResp.wire_fees[wireType]) {
      if (isCurrentlyValid(wf.start_date, wf.end_date)) {
        haveAccountFees = true;
        break;
      }
    }
    if (!haveAccountFees) {
      reporter.reportError(`missing wire account fees for ${wireType}`);
    }
  }
}
