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

import {
  OperationFail,
  OperationOk,
  OperationResult,
  opFixedSuccess,
  opKnownFailure,
} from "./operation.js";

/**
 * IBAN validation.
 *
 * Currently only validates the checksum.
 *
 * It does not validate:
 * - Country-specific length
 * - Country-specific checksums
 *
 * The country list is also not complete.
 *
 * @author Florian Dold <dold@taler.net>
 * @author sebasjm
 */

declare const __iban: unique symbol;
export type IbanString = string & { [__iban]: true };

export enum ParseIbanError {
  /**
   * The country is not the one listed in https://www.swift.com/resource/iban-registry-pdf
   */
  UNSUPPORTED_COUNTRY,
  /**
   * The IBAN length should be less than 34 chars
   */
  TOO_LONG,
  /**
   * The IBAN length should be greater than 4 chars
   */
  TOO_SHORT,
  /**
   * The IBAN should only have letters and numbers
   */
  INVALID_CHARSET,
  /**
   * Computed MOD-97-10 checksum doesn't match
   */
  INVALID_CHECKSUM,
}

/**
 * @deprecated
 */
export type IbanValidationResult =
  | { type: "invalid"; code: ParseIbanError }
  | {
      type: "valid";
      normalizedIban: string;
    };

export interface IbanCountryInfo {
  name: string;
  isSepa?: boolean;
  length?: number;
}

const ccZero = "0".charCodeAt(0);
const ccNine = "9".charCodeAt(0);
const ccA = "A".charCodeAt(0);
const ccZ = "Z".charCodeAt(0);

/**
 * Append a IBAN digit(s) based on a char code.
 */
function appendDigit(digits: number[], cc: number): boolean {
  if (cc >= ccZero && cc <= ccNine) {
    digits.push(cc - ccZero);
  } else if (cc >= ccA && cc <= ccZ) {
    const n = cc - ccA + 10;
    digits.push(Math.floor(n / 10) % 10);
    digits.push(n % 10);
  } else {
    return false;
  }
  return true;
}

/**
 * Compute MOD-97-10 as per ISO/IEC 7064:2003.
 */
function mod97(digits: number[]): number {
  let i = 0;
  let modAccum = 0;
  while (i < digits.length) {
    let n = 0;
    while (n < 9 && i < digits.length) {
      modAccum = modAccum * 10 + digits[i];
      i++;
      n++;
    }
    modAccum = modAccum % 97;
  }
  return modAccum;
}

export function convertHUF_BBANtoIBAN(
  value: string,
):
  | OperationOk<IbanString>
  | OperationFail<ParseIbanError.TOO_LONG>
  | OperationFail<ParseIbanError.TOO_SHORT>
  | OperationFail<ParseIbanError.INVALID_CHARSET> {
  if (value.startsWith("HU")) {
    const maybeIban = parseIban(value);
    if (maybeIban.case === "ok") {
      return opFixedSuccess<IbanString>(maybeIban.body);
    }
  }
  if (value.length > 24) {
    return opKnownFailure(ParseIbanError.TOO_LONG);
  }
  if (value.length < 16) {
    return opKnownFailure(ParseIbanError.TOO_SHORT);
  }
  if (!/[0-9+]/.test(value)) {
    return opKnownFailure(ParseIbanError.INVALID_CHARSET);
  }

  const bban = value.length === 16 ? `${value}00000000` : value;
  return opFixedSuccess<IbanString>(constructIban("HU", bban));
}

/**
 * Fake a BBAn conversion from CHF to IBAN.
 * Note that this conversion does not exist in practice,
 * it's only used for testing.
 */
export function convertCHF_BBANtoIBAN(
  value: string,
):
  | OperationOk<IbanString>
  | OperationFail<ParseIbanError.TOO_LONG>
  | OperationFail<ParseIbanError.TOO_SHORT>
  | OperationFail<ParseIbanError.INVALID_CHARSET> {
  if (value.startsWith("CH")) {
    const maybeIban = parseIban(value);
    if (maybeIban.case === "ok") {
      return opFixedSuccess<IbanString>(maybeIban.body);
    }
  }
  if (value.length > 24) {
    return opKnownFailure(ParseIbanError.TOO_LONG);
  }
  if (value.length < 16) {
    return opKnownFailure(ParseIbanError.TOO_SHORT);
  }
  if (!/[0-9+]/.test(value)) {
    return opKnownFailure(ParseIbanError.INVALID_CHARSET);
  }

  const bban = value;
  return opFixedSuccess<IbanString>(constructIban("CH", bban));
}

/**
 * Check the IBAN is correct and return canonical form
 * @param ibanString
 * @returns
 */
export function parseIban(
  ibanString: string,
): OperationResult<IbanString, ParseIbanError> {
  if (ibanString.length < 4) {
    return opKnownFailure(ParseIbanError.TOO_SHORT);
  }
  if (ibanString.length > 34) {
    return opKnownFailure(ParseIbanError.TOO_LONG);
  }

  const myIban = ibanString.toUpperCase().replace(/[\s-\._]/g, "");
  const countryCode = myIban.substring(0, 2);
  const countryInfo = ibanCountryInfoTable[countryCode];

  if (!countryInfo) {
    return opKnownFailure(ParseIbanError.UNSUPPORTED_COUNTRY);
  }

  let digits: number[] = [];

  for (let i = 4; i < myIban.length; i++) {
    const cc = myIban.charCodeAt(i);
    if (!appendDigit(digits, cc)) {
      return opKnownFailure(ParseIbanError.INVALID_CHARSET);
    }
  }

  for (let i = 0; i < 4; i++) {
    const cc = myIban.charCodeAt(i);
    if (!appendDigit(digits, cc)) {
      return opKnownFailure(ParseIbanError.INVALID_CHARSET);
    }
  }

  const rem = mod97(digits);
  if (rem === 1) {
    return opFixedSuccess<IbanString>(myIban as IbanString);
  } else {
    return opKnownFailure(ParseIbanError.INVALID_CHECKSUM);
  }
}

/**
 * @deprecated use parseIban
 *
 * @param ibanString
 * @returns
 */
export function validateIban(ibanString: string): IbanValidationResult {
  if (ibanString.length < 4) {
    return {
      type: "invalid",
      code: ParseIbanError.TOO_SHORT,
    };
  }
  if (ibanString.length > 34) {
    return {
      type: "invalid",
      code: ParseIbanError.TOO_LONG,
    };
  }

  const myIban = ibanString.toLocaleUpperCase().replace(" ", "");
  const countryCode = myIban.substring(0, 2);
  const countryInfo = ibanCountryInfoTable[countryCode];

  if (!countryInfo) {
    return {
      type: "invalid",
      code: ParseIbanError.UNSUPPORTED_COUNTRY,
    };
  }

  let digits: number[] = [];

  for (let i = 4; i < myIban.length; i++) {
    const cc = myIban.charCodeAt(i);
    if (!appendDigit(digits, cc)) {
      return {
        type: "invalid",
        code: ParseIbanError.INVALID_CHARSET,
      };
    }
  }

  for (let i = 0; i < 4; i++) {
    if (!appendDigit(digits, ibanString.charCodeAt(i))) {
      return {
        type: "invalid",
        code: ParseIbanError.INVALID_CHARSET,
      };
    }
  }

  const rem = mod97(digits);
  if (rem === 1) {
    return {
      type: "valid",
      normalizedIban: myIban,
    };
  } else {
    return {
      type: "invalid",
      code: ParseIbanError.INVALID_CHECKSUM,
    };
  }
}

export function generateIban(countryCode: string, length: number): IbanString {
  let bban = "";

  for (let i = 0; i < length; i++) {
    const cc = ccZero + (Math.floor(Math.random() * 100) % 10);
    bban += String.fromCharCode(cc);
  }

  return constructIban(countryCode, bban);
}

export function constructIban(countryCode: string, bban: string): IbanString {
  let ibanSuffix = "";
  let digits: number[] = [];

  for (let i = 0; i < bban.length; i++) {
    const cc = bban.charCodeAt(i);
    appendDigit(digits, cc);
    ibanSuffix += String.fromCharCode(cc);
  }

  appendDigit(digits, countryCode.charCodeAt(0));
  appendDigit(digits, countryCode.charCodeAt(1));

  // Try using "00" as check digits
  appendDigit(digits, ccZero);
  appendDigit(digits, ccZero);

  const requiredChecksum = 98 - mod97(digits);

  const checkDigit1 = Math.floor(requiredChecksum / 10) % 10;
  const checkDigit2 = requiredChecksum % 10;

  return (countryCode + checkDigit1 + checkDigit2 + ibanSuffix) as IbanString;
}

/**
 * Incomplete list, see https://www.swift.com/resource/iban-registry-pdf
 */
export const ibanCountryInfoTable: Record<string, IbanCountryInfo> = {
  AE: { name: "U.A.E." },
  AF: { name: "Afghanistan" },
  AL: { name: "Albania" },
  AM: { name: "Armenia" },
  AN: { name: "Netherlands Antilles" },
  AR: { name: "Argentina" },
  AT: { name: "Austria" },
  AU: { name: "Australia" },
  AZ: { name: "Azerbaijan" },
  BA: { name: "Bosnia and Herzegovina" },
  BD: { name: "Bangladesh" },
  BE: { name: "Belgium" },
  BG: { name: "Bulgaria" },
  BH: { name: "Bahrain" },
  BN: { name: "Brunei Darussalam" },
  BO: { name: "Bolivia" },
  BR: { name: "Brazil" },
  BT: { name: "Bhutan" },
  BY: { name: "Belarus" },
  BZ: { name: "Belize" },
  CA: { name: "Canada" },
  CG: { name: "Congo" },
  CH: { name: "Switzerland" },
  CI: { name: "Cote d'Ivoire" },
  CL: { name: "Chile" },
  CM: { name: "Cameroon" },
  CN: { name: "People's Republic of China" },
  CO: { name: "Colombia" },
  CR: { name: "Costa Rica" },
  CS: { name: "Serbia and Montenegro" },
  CZ: { name: "Czech Republic" },
  DE: { name: "Germany" },
  DK: { name: "Denmark" },
  DO: { name: "Dominican Republic" },
  DZ: { name: "Algeria" },
  EC: { name: "Ecuador" },
  EE: { name: "Estonia" },
  EG: { name: "Egypt" },
  ER: { name: "Eritrea" },
  ES: { name: "Spain" },
  ET: { name: "Ethiopia" },
  FI: { name: "Finland" },
  FO: { name: "Faroe Islands" },
  FR: { name: "France" },
  GB: { name: "United Kingdom" },
  GD: { name: "Caribbean" },
  GE: { name: "Georgia" },
  GL: { name: "Greenland" },
  GR: { name: "Greece" },
  GT: { name: "Guatemala" },
  HK: { name: "Hong Kong S.A.R." },
  HN: { name: "Honduras" },
  HR: { name: "Croatia" },
  HT: { name: "Haiti" },
  HU: { name: "Hungary" },
  ID: { name: "Indonesia" },
  IE: { name: "Ireland" },
  IL: { name: "Israel" },
  IN: { name: "India" },
  IQ: { name: "Iraq" },
  IR: { name: "Iran" },
  IS: { name: "Iceland" },
  IT: { name: "Italy" },
  JM: { name: "Jamaica" },
  JO: { name: "Jordan" },
  JP: { name: "Japan" },
  KE: { name: "Kenya" },
  KG: { name: "Kyrgyzstan" },
  KH: { name: "Cambodia" },
  KR: { name: "South Korea" },
  KW: { name: "Kuwait" },
  KZ: { name: "Kazakhstan" },
  LA: { name: "Laos" },
  LB: { name: "Lebanon" },
  LI: { name: "Liechtenstein" },
  LK: { name: "Sri Lanka" },
  LT: { name: "Lithuania" },
  LU: { name: "Luxembourg" },
  LV: { name: "Latvia" },
  LY: { name: "Libya" },
  MA: { name: "Morocco" },
  MC: { name: "Principality of Monaco" },
  MD: { name: "Moldava" },
  ME: { name: "Montenegro" },
  MK: { name: "Former Yugoslav Republic of Macedonia" },
  ML: { name: "Mali" },
  MM: { name: "Myanmar" },
  MN: { name: "Mongolia" },
  MO: { name: "Macau S.A.R." },
  MT: { name: "Malta" },
  MV: { name: "Maldives" },
  MX: { name: "Mexico" },
  MY: { name: "Malaysia" },
  NG: { name: "Nigeria" },
  NI: { name: "Nicaragua" },
  NL: { name: "Netherlands" },
  NO: { name: "Norway" },
  NP: { name: "Nepal" },
  NZ: { name: "New Zealand" },
  OM: { name: "Oman" },
  PA: { name: "Panama" },
  PE: { name: "Peru" },
  PH: { name: "Philippines" },
  PK: { name: "Islamic Republic of Pakistan" },
  PL: { name: "Poland" },
  PR: { name: "Puerto Rico" },
  PT: { name: "Portugal" },
  PY: { name: "Paraguay" },
  QA: { name: "Qatar" },
  RE: { name: "Reunion" },
  RO: { name: "Romania" },
  RS: { name: "Serbia" },
  RU: { name: "Russia" },
  RW: { name: "Rwanda" },
  SA: { name: "Saudi Arabia" },
  SE: { name: "Sweden" },
  SG: { name: "Singapore" },
  SI: { name: "Slovenia" },
  SK: { name: "Slovak" },
  SN: { name: "Senegal" },
  SO: { name: "Somalia" },
  SR: { name: "Suriname" },
  SV: { name: "El Salvador" },
  SY: { name: "Syria" },
  TH: { name: "Thailand" },
  TJ: { name: "Tajikistan" },
  TM: { name: "Turkmenistan" },
  TN: { name: "Tunisia" },
  TR: { name: "Turkey" },
  TT: { name: "Trinidad and Tobago" },
  TW: { name: "Taiwan" },
  TZ: { name: "Tanzania" },
  UA: { name: "Ukraine" },
  US: { name: "United States" },
  UY: { name: "Uruguay" },
  VA: { name: "Vatican" },
  VE: { name: "Venezuela" },
  VN: { name: "Viet Nam" },
  YE: { name: "Yemen" },
  ZA: { name: "South Africa" },
  ZW: { name: "Zimbabwe" },
};
