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

/**
 * Support for the Taler donation authority (donau).
 *
 * @author Florian Dold <dold@taler.net>
 */

/**
 * Imports.
 */
import {
  Amount,
  AmountLike,
  Amounts,
  AmountString,
  BlindedUniqueDonationIdentifier,
  createHashContext,
  decodeCrock,
  DenomKeyType,
  DonationReceiptSignature,
  DonationUnitKeyGroupRsa,
  DonauHttpClient,
  DonauKeysResponse,
  DonauStatementItem,
  DonauUnitPubKey,
  EmptyObject,
  encodeCrock,
  GetDonauResponse,
  GetDonauStatementsRequest,
  GetDonauStatementsResponse,
  getRandomBytes,
  HashCodeString,
  j2s,
  Logger,
  NotificationType,
  SetDonauRequest,
  SignedTokenEnvelope,
  stringToBytes,
  succeedOrThrow,
} from "@gnu-taler/taler-util";
import {
  ConfigRecordKey,
  DonationPlanchetRecord,
  DonationReceiptRecord,
  DonationReceiptStatus,
  WalletDbHelpers,
} from "./db.js";
import { WalletExecutionContext } from "./index.js";

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

interface DonationReceiptGroup {
  receipts: DonationReceiptRecord[];
  donauBaseUrl: string;
  donorTaxId: string;
  donorTaxIdHash: string;
  donorHashSalt: string;
  year: number;
  currency: string;
};

/**
 * Implementation of the getDonauStatements
 * wallet-core request.
 */
export async function handleGetDonauStatements(
  wex: WalletExecutionContext,
  req: GetDonauStatementsRequest,
): Promise<GetDonauStatementsResponse> {
  await submitDonationReceipts(wex, req.donauBaseUrl);
  return {
    statements: await fetchDonauStatements(wex, req.donauBaseUrl),
  };
}

async function submitDonationReceipts(
  wex: WalletExecutionContext,
  donauBaseUrl?: string,
): Promise<void> {
  const receipts = await wex.db.runReadOnlyTx(
    {
      storeNames: ["donationReceipts"],
    },
    async (tx) => {
      let receipts;
      if (donauBaseUrl) {
        receipts = await tx.donationReceipts.indexes.byStatusAndDonauBaseUrl.getAll([
          DonationReceiptStatus.Pending,
          donauBaseUrl,
        ]);
      } else {
        receipts = await tx.donationReceipts.indexes.byStatus.getAll(
          DonationReceiptStatus.Pending,
        );
      }
      return receipts;
    },
  );

  const groups = groupDonauReceipts(receipts);
  for (const group of groups) {
    const donauClient = new DonauHttpClient(group.donauBaseUrl);
    const conf = succeedOrThrow(await donauClient.getConfig());

    logger.info(`submitting donation receipts (${group.year}, ${group.donorTaxIdHash})`);
    succeedOrThrow(
      await donauClient.submitDonationReceipts({
        h_donor_tax_id: group.donorTaxIdHash,
        donation_receipts: group.receipts.map((x) => ({
          donation_unit_sig: x.donationUnitSig,
          h_donation_unit_pub: x.donationUnitPubHash,
          nonce: x.udiNonce,
        })),
        donation_year: group.year,
      }),
    );

    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      let donauSummary = await tx.donationSummaries.get([
        group.donauBaseUrl,
        group.year,
        group.currency,
      ]);
      if (!donauSummary) {
        logger.warn(
          `no donau summary (your database might be an old development version, upgrade not supported)`,
        );
        return;
      }

      donauSummary.legalDomain = conf.legal_domain;

      for (const bi of group.receipts) {
        const receipt = await tx.donationReceipts.get(bi.udiNonce);
        if (!receipt) {
          continue;
        }
        switch (receipt.status) {
          case DonationReceiptStatus.Pending: {
            receipt.status = DonationReceiptStatus.DoneSubmitted;
            await tx.donationReceipts.put(receipt);
            donauSummary.amountReceiptsSubmitted = Amounts.stringify(
              Amounts.add(donauSummary.amountReceiptsSubmitted, receipt.value)
                .amount,
            );
            break;
          }
        }
      }

      await tx.donationSummaries.put(donauSummary);
      tx.notify({
        type: NotificationType.BalanceChange,
        hintTransactionId: "donau",
      });
    });
  }
}

async function fetchDonauStatements(
  wex: WalletExecutionContext,
  donauBaseUrl?: string,
): Promise<DonauStatementItem[]> {
  const receipts = await wex.db.runReadOnlyTx(
    {
      storeNames: ["donationReceipts"],
    },
    async (tx) => {
      let receipts;
      if (donauBaseUrl) {
        receipts = await tx.donationReceipts.indexes.byStatusAndDonauBaseUrl.getAll([
          DonationReceiptStatus.DoneSubmitted,
          donauBaseUrl,
        ]);
      } else {
        receipts = await tx.donationReceipts.indexes.byStatus.getAll(
          DonationReceiptStatus.DoneSubmitted,
        );
      }
      return receipts;
    },
  );

  const statements: DonauStatementItem[] = [];
  const groups = groupDonauReceipts(receipts);
  for (const group of groups) {
    const donauClient = new DonauHttpClient(group.donauBaseUrl);
    const conf = succeedOrThrow(await donauClient.getConfig());

    const stmt = succeedOrThrow(
      await donauClient.getDonationStatement(
        group.year,
        group.donorTaxIdHash,
      ),
    );
    const parsedDonauUrl = new URL(group.donauBaseUrl);
    const proto = parsedDonauUrl.protocol == "http:" ? "donau+http" : "donau";
    const taxIdEnc = encodeURIComponent(group.donorTaxId);
    const saltEnc = encodeURIComponent(group.donorHashSalt);
    statements.push({
      donationStatementSig: stmt.donation_statement_sig,
      donauPub: stmt.donau_pub,
      total: stmt.total,
      year: group.year,
      legalDomain: conf.legal_domain,
      // FIXME: Generate this using some helper
      // FIXME: What about a donau not at the root path?
      uri: `${proto}://${parsedDonauUrl.host}/${group.year}/${taxIdEnc}/${saltEnc}?total=${stmt.total}&sig=ED25519:${stmt.donation_statement_sig}`,
    });
  }

  return statements;
}

function groupDonauReceipts(
  receipts: DonationReceiptRecord[],
): DonationReceiptGroup[] {
  const donauUrlSet = new Set<string>(
    receipts.map((x) => x.donauBaseUrl),
  );
  const donauUrls = [...donauUrlSet];

  const groups: DonationReceiptGroup[] = [];
  for (const donauUrl of donauUrls) {
    const buckets: Map<string, DonationReceiptRecord[]> = new Map();
    for (const receipt of receipts) {
      if (receipt.donauBaseUrl != donauUrl) {
        continue;
      }
      const key = `${receipt.donorTaxIdHash}-${receipt.donationYear}`;
      let bucket: DonationReceiptRecord[] | undefined;
      bucket = buckets.get(key);
      if (!bucket) {
        bucket = [];
        buckets.set(key, bucket);
      }
      bucket.push(receipt);
    }

    for (const [key, value] of buckets) {
      const r0 = value[0];
      if (!r0) {
        continue;
      }

      groups.push({
        receipts: value,
        donauBaseUrl: donauUrl,
        donorTaxId: r0.donorTaxId,
        donorTaxIdHash: r0.donorTaxIdHash,
        donorHashSalt: r0.donorHashSalt,
        year: r0.donationYear,
        currency: Amounts.currencyOf(r0.value),
      })
      const year = r0.donationYear;
      const donauBaseUrl = r0.donauBaseUrl;
      const currency = Amounts.currencyOf(r0.value);
    }
  }

  return groups;
}

/**
 * Implementation of the setDonau
 * wallet-core request.
 */
export async function handleSetDonau(
  wex: WalletExecutionContext,
  req: SetDonauRequest,
): Promise<EmptyObject> {
  // FIXME: This should be idempotent, do not re-salt
  // for same taxpayer ID.
  const salt = getRandomBytes(32);
  // Hashing as defined in lsd0013.
  const idHasher = createHashContext();
  idHasher.update(stringToBytes(req.taxPayerId + "\0"));
  idHasher.update(stringToBytes(encodeCrock(salt) + "\0"));
  const saltedId = idHasher.finish();
  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const oldRec = await WalletDbHelpers.getConfig(
      tx,
      ConfigRecordKey.DonauConfig,
    );
    if (
      oldRec &&
      oldRec.value.donauBaseUrl === req.donauBaseUrl &&
      oldRec.value.donauTaxId === req.taxPayerId
    ) {
      // Be idempotent, re-use old salt.
      return;
    }
    await tx.config.put({
      key: ConfigRecordKey.DonauConfig,
      value: {
        donauBaseUrl: req.donauBaseUrl,
        donauTaxId: req.taxPayerId,
        donauSalt: encodeCrock(salt),
        donauTaxIdHash: encodeCrock(saltedId),
      },
    });
  });
  return {};
}

/**
 * Implementation of the getDonau
 * wallet-core request.
 */
export async function handleGetDonau(
  wex: WalletExecutionContext,
  req: EmptyObject,
): Promise<GetDonauResponse> {
  const currentDonauInfo = await wex.db.runAllStoresReadWriteTx(
    {},
    async (tx) => {
      const res = await WalletDbHelpers.getConfig(
        tx,
        ConfigRecordKey.DonauConfig,
      );
      if (!res) {
        return undefined;
      }
      return {
        donauBaseUrl: res.value.donauBaseUrl,
        taxPayerId: res.value.donauTaxId,
      };
    },
  );
  return { currentDonauInfo };
}

/**
 * Info about a donation unit key from the donau.
 */
interface CandidateDonationUnit {
  value: AmountString;
  unitHash: HashCodeString;
  unitKey: DonauUnitPubKey;
}

/**
 * Filter out applicable donation units
 * from the donau keys response.
 */
async function getCandidateDonationUnits(
  resp: DonauKeysResponse,
  currentYear: number,
  amount: AmountLike,
): Promise<CandidateDonationUnit[]> {
  const candidates: CandidateDonationUnit[] = [];
  logger.info(`finding donau candidates for current year ${currentYear}`);
  for (const g of resp.donation_units) {
    if (Amounts.cmp(g.value, amount) > 0) {
      continue;
    }
    if (g.donation_unit_pub.cipher !== "RSA") {
      continue;
    }
    if (g.year != currentYear) {
      continue;
    }
    if (g.lost) {
      continue;
    }
    candidates.push({
      unitHash: g.donation_unit_pub.pub_key_hash,
      unitKey: {
        cipher: DenomKeyType.Rsa,
        age_mask: 0,
        rsa_public_key: g.donation_unit_pub.rsa_public_key,
      },
      value: g.value,
    });
  }
  candidates.sort((a, b) => Amounts.cmp(b.value, a.value));
  return candidates;
}

/**
 * Hash the unique donation identifier (UDI) nonce (udiNonce)
 * and tax id hash to obtain the UDI hash (udiHash).
 */
function hashUdi(udiNonce: Uint8Array, taxIdHash: Uint8Array): Uint8Array {
  const hc = createHashContext();
  hc.update(taxIdHash);
  // Do this to be compatible with the (currently bad) donau crypto
  hc.update(udiNonce);
  return hc.finish();
}

/**
 * Generate donau planchets for a purchase (identified by the proposal ID).
 *
 * Preconditions:
 * - The purchase must have its choiceIndex and rec.donau* properties set.
 */
export async function generateDonauPlanchets(
  wex: WalletExecutionContext,
  proposalId: string,
): Promise<void> {
  const res = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    const rec = await tx.purchases.get(proposalId);
    if (!rec) {
      return undefined;
    }
    if (!rec.donauBaseUrl) {
      return undefined;
    }
    if (!rec.donauAmount) {
      throw Error("db consistency error: donau amout not set");
    }
    if (typeof rec.donauYear !== "number") {
      throw Error("db consistency error: donau year not a number");
    }
    if (typeof rec.choiceIndex !== "number") {
      throw Error("choice required for donau");
    }
    if (rec.donauTaxIdHash == null) {
      throw Error("donau tax id hash required");
    }
    if (rec.donauTaxId == null) {
      throw Error("donau tax id required");
    }
    if (rec.donauTaxIdSalt == null) {
      throw Error("donau tax id salt required");
    }
    return {
      donauBaseUrl: rec.donauBaseUrl,
      donauAmount: rec.donauAmount,
      donauYear: rec.donauYear,
      donauOutputIndex: rec.donauOutputIndex,
      taxIdHash: rec.donauTaxIdHash,
      taxIdSalt: rec.donauTaxIdSalt,
      taxId: rec.donauTaxId,
      choiceIndex: rec.choiceIndex,
      purchaseRec: rec,
    };
  });

  if (!res) {
    return;
  }

  logger.info(`creating budi for ${j2s(res)}`);

  const client = new DonauHttpClient(res.donauBaseUrl);

  const keysResp = succeedOrThrow(await client.getKeys());

  const candidates = await getCandidateDonationUnits(
    keysResp,
    res.donauYear,
    res.donauAmount,
  );

  logger.info(`created ${candidates.length} donau candidates`);

  let remaining = Amount.from(res.donauAmount);
  const selection: CandidateDonationUnit[] = [];

  let i = 0;
  while (i < candidates.length) {
    if (remaining.isZero()) {
      break;
    }
    const cand = candidates[i];
    if (Amounts.cmp(remaining, cand.value) >= 0) {
      selection.push(cand);
      remaining = remaining.sub(cand.value);
    } else {
      i++;
    }
  }

  const donauPlanchets: DonationPlanchetRecord[] = [];

  for (let udiIndex = 0; udiIndex < selection.length; udiIndex++) {
    const sel = selection[udiIndex];

    const udiNonce = getRandomBytes(32);

    let blindedUdi: BlindedUniqueDonationIdentifier;
    const bks = getRandomBytes(32);

    switch (sel.unitKey.cipher) {
      case DenomKeyType.Rsa: {
        const hm = encodeCrock(hashUdi(udiNonce, decodeCrock(res.taxIdHash)));
        logger.info(`pub at blinding: ${sel.unitKey.rsa_public_key}`);
        logger.info(`bks at blinding: ${encodeCrock(bks)}`);
        logger.info(`hm at blinding: ${hm}`);
        const blindRes = await wex.cryptoApi.rsaBlind({
          pub: sel.unitKey.rsa_public_key,
          hm,
          bks: encodeCrock(bks),
        });
        blindedUdi = {
          cipher: "RSA",
          rsa_blinded_identifier: blindRes.blinded,
        };
        break;
      }
      default:
        throw Error("key type not supported");
    }

    donauPlanchets.push({
      donauBaseUrl: res.donauBaseUrl,
      donationUnitPubHash: sel.unitHash,
      donationYear: res.donauYear,
      proposalId,
      udiIndex,
      donorTaxIdHash: res.taxIdHash,
      donorHashSalt: res.taxIdSalt,
      donorTaxId: res.taxId,
      udiNonce: encodeCrock(udiNonce),
      blindedUdi,
      bks: encodeCrock(bks),
      value: sel.value,
    });
  }

  logger.info(`created ${donauPlanchets.length} donau planchets`);

  logger.trace(`donau planchets: ${j2s(donauPlanchets)}`);

  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const rec = await tx.purchases.get(proposalId);
    if (!rec) {
      return undefined;
    }
    const existingPlanchets =
      await tx.donationPlanchets.indexes.byProposalId.getAllKeys([
        rec.proposalId,
      ]);
    if (existingPlanchets.length > 0) {
      return;
    }
    for (const dp of donauPlanchets) {
      await tx.donationPlanchets.put(dp);
    }
  });
}

/**
 * Accept blinded signatures from the donau.
 *
 * Unblind them and store them in the wallet database.
 */
export async function acceptDonauBlindSigs(
  wex: WalletExecutionContext,
  donauBaseUrl: string,
  donauPlanchets: DonationPlanchetRecord[],
  donauBlindedSigs: SignedTokenEnvelope[],
): Promise<void> {
  if (donauPlanchets.length != donauBlindedSigs.length) {
    throw Error();
  }

  const client = new DonauHttpClient(donauBaseUrl);

  // FIXME: Take this from the database instead of querying each time.
  const keysResp = succeedOrThrow(await client.getKeys());

  // FIXME: Take this from the database instead of querying each time.
  const conf = succeedOrThrow(await client.getConfig());

  const sigs: DonationReceiptSignature[] = [];

  for (let i = 0; i < donauBlindedSigs.length; i++) {
    const myPlanchet = donauPlanchets[i];
    const myBlindSig = donauBlindedSigs[i].blind_sig;
    let unitKey: DonationUnitKeyGroupRsa | undefined;
    for (let j = 0; j < keysResp.donation_units.length; j++) {
      const candidate = keysResp.donation_units[j];
      if (
        candidate.donation_unit_pub.cipher === "RSA" &&
        candidate.donation_unit_pub.pub_key_hash ===
          myPlanchet.donationUnitPubHash
      ) {
        unitKey = candidate as DonationUnitKeyGroupRsa;
        break;
      }
    }
    if (!unitKey) {
      throw Error("donation unit key not found");
    }
    logger.info(`found unit key ${j2s(unitKey)}`);
    if (myBlindSig.cipher !== DenomKeyType.Rsa) {
      throw Error("only RSA supported");
    }
    const unblindSig = await wex.cryptoApi.rsaUnblind({
      pk: unitKey.donation_unit_pub.rsa_public_key,
      bk: myPlanchet.bks,
      blindedSig: myBlindSig.blinded_rsa_signature,
    });
    const udiHash = hashUdi(
      decodeCrock(myPlanchet.udiNonce),
      decodeCrock(myPlanchet.donorTaxIdHash),
    );
    const hm = encodeCrock(udiHash);
    logger.info(
      `pub at verification: ${unitKey.donation_unit_pub.rsa_public_key}`,
    );
    logger.info(`bks at verification: ${myPlanchet.bks}`);
    logger.info(`hm at verification: ${hm}`);
    const verifyRes = await wex.cryptoApi.rsaVerifyDirect({
      hm,
      pk: unitKey.donation_unit_pub.rsa_public_key,
      sig: unblindSig.sig,
    });
    if (!verifyRes.valid) {
      throw Error("invalid donau signature");
    }
    sigs.push({
      cipher: "RSA",
      rsa_signature: unblindSig.sig,
    });
  }

  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    for (let i = 0; i < donauBlindedSigs.length; i++) {
      const myPlanchet = donauPlanchets[i];
      const existingReceipt = await tx.donationReceipts.get(
        myPlanchet.udiNonce,
      );
      if (existingReceipt) {
        continue;
      }
      const year = myPlanchet.donationYear;
      const currency = Amounts.currencyOf(myPlanchet.value);
      let donauSummary = await tx.donationSummaries.get([
        donauBaseUrl,
        year,
        Amounts.currencyOf(myPlanchet.value),
      ]);
      const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency));
      if (!donauSummary) {
        donauSummary = {
          year,
          amountReceiptsAvailable: zero,
          amountReceiptsSubmitted: zero,
          currency,
          donauBaseUrl,
        };
      }
      donauSummary.amountReceiptsAvailable = Amounts.stringify(
        Amounts.add(donauSummary.amountReceiptsAvailable, myPlanchet.value)
          .amount,
      );
      await tx.donationReceipts.put({
        donationUnitSig: sigs[i],
        donationUnitPubHash: myPlanchet.donationUnitPubHash,
        donauBaseUrl: myPlanchet.donauBaseUrl,
        proposalId: myPlanchet.proposalId,
        udiNonce: myPlanchet.udiNonce,
        status: DonationReceiptStatus.Pending,
        donorHashSalt: myPlanchet.donorHashSalt,
        donorTaxId: myPlanchet.donorTaxId,
        donorTaxIdHash: myPlanchet.donorTaxIdHash,
        donationYear: myPlanchet.donationYear,
        udiIndex: myPlanchet.udiIndex,
        value: myPlanchet.value,
      });
      donauSummary.legalDomain = conf.legal_domain;
      await tx.donationSummaries.put(donauSummary);
      tx.notify({
        type: NotificationType.BalanceChange,
        hintTransactionId: "donau",
      });
    }
  });
}
