/*
 This file is part of GNU Taler
 (C) 2024 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 {
  AbsoluteTime,
  AccountLimit,
  AmountJson,
  AmountLike,
  Amounts,
  AmountString,
  codecForAccountKycStatus,
  Duration,
  HttpStatusCode,
  Logger,
  TalerPreciseTimestamp,
} from "@gnu-taler/taler-util";
import {
  HttpResponse,
  readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http";
import {
  cancelableFetch,
  cancelableLongPoll,
  TaskRunResult,
} from "./common.js";
import {
  DbPreciseTimestamp,
  timestampAbsoluteFromDb,
  timestampPreciseToDb,
} from "./db.js";
import { ReadyExchangeSummary } from "./exchanges.js";
import { WalletExecutionContext } from "./index.js";

/**
 * @fileoverview Helpers for KYC.
 * @author Florian Dold <dold@taler.net>
 */

/**
 * Logger.
 */
const logger = new Logger("kyc.ts");

export interface SimpleLimitInfo {
  kycHardLimit: AmountString | undefined;
  kycSoftLimit: AmountString | undefined;
}

export interface MultiExchangeLimitInfo {
  kycHardLimit: AmountString | undefined;
  kycSoftLimit: AmountString | undefined;

  /**
   * Exchanges that would require soft KYC.
   */
  kycExchanges: string[];
}

/**
 * Return the smallest given amount, where an undefined amount
 * is interpreted the larger amount.
 */
function minDefAmount(
  a: AmountLike | undefined,
  b: AmountLike | undefined,
): AmountJson {
  if (a == null) {
    if (b == null) {
      throw Error();
    }
    return Amounts.jsonifyAmount(b);
  }
  if (b == null) {
    if (a == null) {
      throw Error();
    }
    return Amounts.jsonifyAmount(a);
  }
  return Amounts.min(a, b);
}

/**
 * Add to an amount.
 * Interprets the second argument as zero if not defined.
 */
function addDefAmount(a: AmountLike, b: AmountLike | undefined): AmountJson {
  if (b == null) {
    return Amounts.jsonifyAmount(a);
  }
  return Amounts.add(a, b).amount;
}

export function getDepositLimitInfo(
  exchanges: ReadyExchangeSummary[],
  instructedAmount: AmountLike,
): MultiExchangeLimitInfo {
  let kycHardLimit: AmountJson | undefined;
  let kycSoftLimit: AmountJson | undefined;
  let kycExchanges: string[] = [];

  // FIXME: Summing up the limits doesn't really make a lot of sense,
  // as the funds at each exchange are limited (by the coins in the wallet),
  // and thus an exchange where we don't have coins but that has a high
  // KYC limit can't meaningfully contribute to the whole limit.

  for (const exchange of exchanges) {
    const exchLim = getSingleExchangeDepositLimitInfo(
      exchange,
      instructedAmount,
    );
    if (exchLim.kycSoftLimit) {
      kycExchanges.push(exchange.exchangeBaseUrl);
      kycSoftLimit = addDefAmount(exchLim.kycSoftLimit, kycSoftLimit);
    }
    if (exchLim.kycHardLimit) {
      kycHardLimit = addDefAmount(exchLim.kycHardLimit, kycHardLimit);
    }
  }

  return {
    kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined,
    kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined,
    kycExchanges,
  };
}

export function getSingleExchangeDepositLimitInfo(
  exchange: ReadyExchangeSummary,
  instructedAmount: AmountLike,
): SimpleLimitInfo {
  let kycHardLimit: AmountJson | undefined;
  let kycSoftLimit: AmountJson | undefined;

  for (let lim of exchange.hardLimits) {
    switch (lim.operation_type) {
      case "DEPOSIT":
      case "AGGREGATE":
        // FIXME: This should consider past deposits and KYC checks
        kycHardLimit = minDefAmount(kycHardLimit, lim.threshold);
        break;
    }
  }

  for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) {
    kycSoftLimit = minDefAmount(kycSoftLimit, limAmount);
  }

  for (let lim of exchange.zeroLimits) {
    switch (lim.operation_type) {
      case "DEPOSIT":
      case "AGGREGATE":
        kycSoftLimit = Amounts.zeroOfAmount(instructedAmount);
        break;
    }
  }

  return {
    kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined,
    kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined,
  };
}

export function getPeerCreditLimitInfo(
  exchange: ReadyExchangeSummary,
  instructedAmount: AmountLike,
): SimpleLimitInfo {
  let kycHardLimit: AmountJson | undefined;
  let kycSoftLimit: AmountJson | undefined;

  for (let lim of exchange.hardLimits) {
    switch (lim.operation_type) {
      case "BALANCE":
      case "MERGE":
        // FIXME: This should consider past merges and KYC checks
        kycHardLimit = minDefAmount(kycHardLimit, lim.threshold);
        break;
    }
  }

  for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) {
    kycSoftLimit = minDefAmount(kycSoftLimit, limAmount);
  }

  for (let lim of exchange.zeroLimits) {
    switch (lim.operation_type) {
      case "BALANCE":
      case "MERGE":
        kycSoftLimit = Amounts.zeroOfAmount(instructedAmount);
        break;
    }
  }

  return {
    kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined,
    kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined,
  };
}

export function checkWithdrawalHardLimitExceeded(
  exchange: ReadyExchangeSummary,
  instructedAmount: AmountLike,
): boolean {
  const limitInfo = getWithdrawalLimitInfo(exchange, instructedAmount);
  return (
    limitInfo.kycHardLimit != null &&
    Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) < 0
  );
}

export function checkPeerCreditHardLimitExceeded(
  exchange: ReadyExchangeSummary,
  instructedAmount: AmountLike,
): boolean {
  const limitInfo = getPeerCreditLimitInfo(exchange, instructedAmount);
  return (
    limitInfo.kycHardLimit != null &&
    Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) < 0
  );
}

export function checkDepositHardLimitExceeded(
  exchanges: ReadyExchangeSummary[],
  instructedAmount: AmountLike,
): boolean {
  const limitInfo = getDepositLimitInfo(exchanges, instructedAmount);
  return (
    limitInfo.kycHardLimit != null &&
    Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) < 0
  );
}

export function getWithdrawalLimitInfo(
  exchange: ReadyExchangeSummary,
  instructedAmount: AmountLike,
): SimpleLimitInfo {
  let kycHardLimit: AmountJson | undefined;
  let kycSoftLimit: AmountJson | undefined;

  for (let lim of exchange.hardLimits) {
    switch (lim.operation_type) {
      case "BALANCE":
      case "WITHDRAW":
        // FIXME: This should consider past withdrawals and KYC checks
        kycHardLimit = minDefAmount(kycHardLimit, lim.threshold);
        break;
    }
  }

  for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) {
    kycSoftLimit = minDefAmount(kycSoftLimit, limAmount);
  }

  for (let lim of exchange.zeroLimits) {
    switch (lim.operation_type) {
      case "BALANCE":
      case "WITHDRAW":
        kycSoftLimit = Amounts.zeroOfAmount(instructedAmount);
        break;
    }
  }

  return {
    kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined,
    kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined,
  };
}

export enum LimitCheckResult {
  Allowed = 0,
  DeniedVerboten = 1,
  DeniedSoft = 3,
}

export async function checkLimit(
  rules: AccountLimit[],
  operation: string,
  amount: AmountLike,
): Promise<LimitCheckResult> {
  let applicableLimit: AccountLimit | undefined;
  for (const rule of rules) {
    // Check if a rule applies and is more specific
    // (smaller threshold)
    // than the previously handled rule (if any).
    if (
      rule.operation_type === operation &&
      Amounts.cmp(amount, rule.threshold) > 0 &&
      (applicableLimit == null ||
        Amounts.cmp(rule.threshold, applicableLimit.threshold) < 0)
    ) {
      applicableLimit = rule;
    }
  }
  if (applicableLimit == null) {
    return LimitCheckResult.Allowed;
  }
  if (applicableLimit.soft_limit) {
    return LimitCheckResult.DeniedSoft;
  }
  return LimitCheckResult.DeniedVerboten;
}

export interface GenericKycStatusReq {
  readonly exchangeBaseUrl: string;
  readonly paytoHash: string;
  readonly accountPub: string;
  readonly accountPriv: string;
  readonly operation: string;
  readonly amount: AmountLike;
  readonly lastCheckStatus?: number | undefined;
  readonly lastCheckCode?: number | undefined;
  readonly lastRuleGen?: number | undefined;
  readonly lastAmlReview?: boolean | undefined;
  readonly lastDeny?: DbPreciseTimestamp | undefined;
  readonly lastBadKycAuth?: boolean;
  readonly haveAccessToken: boolean;
}

export interface GenericKycStatusResp {
  /** If no updated status is present, finish the task with this status. */
  taskResult: TaskRunResult;
  requiresAuth?: boolean;
  updatedStatus?: {
    accessToken?: string;
    lastCheckStatus?: number | undefined;
    lastCheckCode?: number | undefined;
    lastRuleGen?: number | undefined;
    lastAmlReview?: boolean | undefined;
    lastBadKycAuth: boolean;

    lastDeny?: DbPreciseTimestamp | undefined;
    /** New account public key, only present if updated. */
    accountPub?: string;
    /** New account private key, only present if updated. */
    accountPriv?: string;
  };
}

export function isKycOperationDue(st: GenericKycStatusReq): boolean {
  return (
    st.lastDeny == null ||
    AbsoluteTime.isExpired(
      AbsoluteTime.addDuration(
        timestampAbsoluteFromDb(st.lastDeny),
        Duration.fromSpec({ minutes: 30 }),
      ),
    )
  );
}

/**
 * Run a single step of the kyc check algorithm.
 *
 * Returns the updated status if applicable or
 * undefined if there was no progress/change
 * and the kyc check algorithm should be re-executed
 * with exponential back-off.
 */
export async function runKycCheckAlgo(
  wex: WalletExecutionContext,
  st: GenericKycStatusReq,
): Promise<GenericKycStatusResp> {
  const sigResp = await wex.cryptoApi.signWalletKycAuth({
    accountPriv: st.accountPriv,
    accountPub: st.accountPub,
  });

  const headers = {
    ["Account-Owner-Signature"]: sigResp.sig,
    ["Account-Owner-Pub"]: st.accountPub,
  };

  const url = new URL(`kyc-check/${st.paytoHash}`, st.exchangeBaseUrl);

  let doLongpoll: boolean;

  if (st.lastCheckStatus == null || !st.haveAccessToken) {
    doLongpoll = false;
  } else if (
    st.lastCheckStatus === HttpStatusCode.Forbidden ||
    st.lastCheckStatus === HttpStatusCode.Conflict ||
    (st.lastCheckStatus === HttpStatusCode.NotFound && st.lastBadKycAuth)
  ) {
    doLongpoll = true;
    url.searchParams.set("lpt", "1");
  } else if (st.lastAmlReview) {
    doLongpoll = true;
    url.searchParams.set("lpt", "1");
    if (st.lastRuleGen != null) {
      url.searchParams.set("min_rule", `${st.lastRuleGen}`);
    }
  } else {
    doLongpoll = true;
    if (st.lastRuleGen != null) {
      url.searchParams.set("min_rule", `${st.lastRuleGen}`);
    }
  }

  logger.info(`kyc url ${url.href}, longpoll=${doLongpoll}`);

  let kycStatusRes: HttpResponse;

  if (doLongpoll) {
    kycStatusRes = await cancelableLongPoll(wex, url, {
      headers,
    });
  } else {
    kycStatusRes = await cancelableFetch(wex, url, {
      headers,
    });
  }

  logger.trace(
    `request to ${kycStatusRes.requestUrl} returned status ${kycStatusRes.status}`,
  );

  const respJson = await kycStatusRes.json();

  const sameResp =
    kycStatusRes.status === st.lastCheckStatus &&
    respJson.code === st.lastCheckCode &&
    respJson.rule_gen === st.lastRuleGen;

  if (sameResp) {
    logger.trace(`kyc-check response didn't change, retrying with back-off`);
    return {
      taskResult: TaskRunResult.backoff(),
    };
  }

  const updatedStatus: GenericKycStatusResp["updatedStatus"] = {
    lastAmlReview: respJson.aml_review,
    lastCheckCode: respJson.code,
    lastCheckStatus: kycStatusRes.status,
    lastDeny: st.lastDeny,
    lastRuleGen: st.lastRuleGen,
    lastBadKycAuth: st.lastBadKycAuth ?? false,
  };

  const rst: GenericKycStatusResp = {
    // FIXME: take from response or update!!
    taskResult: TaskRunResult.progress(),
    updatedStatus,
    requiresAuth:
      kycStatusRes.status === HttpStatusCode.Conflict ||
      kycStatusRes.status === HttpStatusCode.Forbidden ||
      (kycStatusRes.status === HttpStatusCode.NotFound && st.lastBadKycAuth),
  };

  let exposedLimits: AccountLimit[] | undefined = undefined;

  switch (kycStatusRes.status) {
    case HttpStatusCode.NoContent:
      updatedStatus.lastDeny = undefined;
      break;
    case HttpStatusCode.Ok: {
      const resp = await readSuccessResponseJsonOrThrow(
        kycStatusRes,
        codecForAccountKycStatus(),
      );
      updatedStatus.lastRuleGen = resp.rule_gen;
      updatedStatus.lastDeny = undefined;
      updatedStatus.accessToken = resp.access_token;
      break;
    }
    case HttpStatusCode.Accepted:
      const resp = await readSuccessResponseJsonOrThrow(
        kycStatusRes,
        codecForAccountKycStatus(),
      );
      updatedStatus.accessToken = resp.access_token;
      updatedStatus.lastRuleGen = resp.rule_gen;
      exposedLimits = resp.limits;
      rst.taskResult = TaskRunResult.longpollReturnedPending();
      break;
    case HttpStatusCode.NotFound:
      // FIXME: Check if the private key for the indicated public key is available
      rst.taskResult = TaskRunResult.backoff();
      break;
    case HttpStatusCode.Forbidden:
      // FIXME: Check if we know the key that the exchange
      // claims as the current account pub for KYC.
      rst.taskResult = TaskRunResult.backoff();
      break;
  }

  if (exposedLimits) {
    const checkRes = await checkLimit(exposedLimits, st.operation, st.amount);
    logger.trace(`limit check result: ${LimitCheckResult[checkRes]}`);
    switch (checkRes) {
      case LimitCheckResult.Allowed:
        updatedStatus.lastDeny = undefined;
        break;
      case LimitCheckResult.DeniedSoft:
        updatedStatus.lastDeny = timestampPreciseToDb(
          TalerPreciseTimestamp.now(),
        );
        break;
      case LimitCheckResult.DeniedVerboten:
        // FIXME: This should transition the transaction to failed!
        updatedStatus.lastDeny = timestampPreciseToDb(
          TalerPreciseTimestamp.now(),
        );
        break;
    }
  }

  return rst;
}
