/*
 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/>
 */

/**
 * @fileoverview
 * Implementation of mailbox management in wallet-core.
 * The details of mailbox management are specified in DD70.
 */

import {
  EmptyObject,
  AddMailboxMessageRequest,
  DeleteMailboxMessageRequest,
  MailboxMessagesResponse,
  Logger,
  NotificationType,
  createEddsaKeyPair,
  encodeCrock,
  MailboxConfiguration,
  TalerMailboxInstanceHttpClient,
  decodeCrock,
  eddsaGetPublic,
  sha512,
  succeedOrThrow,
  MailboxMessageRecord,
  TalerProtocolTimestamp,
  stringToBytes,
  hpkeOpenOneshot,
  hpkeCreateSecretKey,
  SendTalerUriMailboxMessageRequest,
  hpkeSealOneshot,
  hpkeSecretKeyGetPublic,
  MailboxMetadata,
  MailboxRegisterRequest,
  MailboxRegisterResult,
  eddsaSign,
  Duration,
  AbsoluteTime,
  TalerSignaturePurpose,
  HttpStatusCode,
  Codec,
  buildCodecForUnion,
  buildCodecForObject,
  codecForConstString,
  codecOptional,
  codecForString,
  opKnownFailure,
  TalerUris,
  parseTalerUri,
  Paytos,
  succeedOrValue,
} from "@gnu-taler/taler-util";
import {
  WalletExecutionContext,
} from "./wallet.js";


const logger = new Logger("mailbox.ts");

/**
 * Add message to the database.
 */
export async function addMailboxMessage(
  wex: WalletExecutionContext,
  req: AddMailboxMessageRequest,
): Promise<EmptyObject> {
  await wex.db.runReadWriteTx(
    {
      storeNames: [
        "mailboxMessages",
      ],
    },
    async (tx) => {
      tx.mailboxMessages.put(req.message);
      tx.notify({
        type: NotificationType.MailboxMessageAdded,
        message: req.message,
      });
    },
  );
  return { };
}

/**
 * Delete message from the database.
 */
export async function deleteMailboxMessage(
  wex: WalletExecutionContext,
  req: DeleteMailboxMessageRequest,
): Promise<EmptyObject> {
  await wex.db.runReadWriteTx(
    {
      storeNames: [
        "mailboxMessages",
      ],
    },
    async (tx) => {
      tx.mailboxMessages.delete ([req.message.originMailboxBaseUrl, req.message.talerUri]);
      tx.notify({
        type: NotificationType.MailboxMessageDeleted,
        message: req.message,
      });
    },
  );
  return { };
}

/**
 * Get messages from the database.
 */
export async function listMailboxMessages(
  wex: WalletExecutionContext,
  req: EmptyObject,
): Promise<MailboxMessagesResponse> {
  const messages = await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "mailboxMessages",
      ],
    },
    async (tx) => {
      return await tx.mailboxMessages.getAll();
    },
  );
  return { messages: messages };
}


/**
 * Register or update mailbox
 */
export async function registerMailbox(
  wex: WalletExecutionContext,
  mailboxConf: MailboxConfiguration,
): Promise<MailboxRegisterResult> {
  const privateSigningKey = decodeCrock(mailboxConf.privateKey);
  const signingKey = eddsaGetPublic(privateSigningKey);
  const encryptionKey = hpkeSecretKeyGetPublic(decodeCrock(mailboxConf.privateEncryptionKey));
  // Message header: 8 byte + 64 byte SHA512 digest to sign
  // We hash ktype|key|encKtype|encKey|expiration
  const messageHeader = new ArrayBuffer(8);
  const expNboBuffer = new ArrayBuffer(8);
  const vMsg = new DataView(messageHeader);
  const vExpNbo = new DataView(expNboBuffer);
  if (mailboxConf.expiration.t_s == "never") {
    throw Error("mailbox can not expire, invalid")
  }
  vExpNbo.setBigUint64(0, BigInt(mailboxConf.expiration.t_s));
  const digestBuffer = new Uint8Array([...stringToBytes("X25519"),
                                       ...encryptionKey,
                                       ...(new Uint8Array(expNboBuffer))]);
  const digest = sha512(digestBuffer);
  vMsg.setUint32(0, messageHeader.byteLength + digest.length);
  vMsg.setUint32(4, TalerSignaturePurpose.MAILBOX_KEYS_UPDATE);
  const msgToSign = new Uint8Array([...(new Uint8Array(messageHeader)), ...digest]);
  const signature = eddsaSign(msgToSign, privateSigningKey);
  const info: MailboxMetadata = {
    signingKey: encodeCrock(signingKey),
    signingKeyType: "EdDSA", // FIXME only supported key type ATM,
    encryptionKey: encodeCrock(encryptionKey),
    encryptionKeyType: "X25519", // FIXME only supported encryption key type ATM
    expiration: mailboxConf.expiration,
  };
  const req: MailboxRegisterRequest = {
    mailbox_metadata: info,
    signature: encodeCrock(signature),
  };
  const mailboxClient = new TalerMailboxInstanceHttpClient(mailboxConf.mailboxBaseUrl, wex.http);

  const resp = await mailboxClient.registerMailbox(req);
  switch (resp.case) {
    case "ok":
      return resp.body;
    case HttpStatusCode.Forbidden:
      throw Error("Access to Mailbox API unauthorized");
    case HttpStatusCode.PaymentRequired:
      return resp.body;
  }
}

/**
 * Get mailbox from the database.
 */
export async function getMailbox(
  wex: WalletExecutionContext,
  mailboxBaseUrl: string,
): Promise<MailboxConfiguration | undefined> {
  return await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "mailboxConfigurations",
      ],
    },
    async (tx) => {
      return await tx.mailboxConfigurations.get(mailboxBaseUrl);
    },
  );
}


/**
 * Create new mailbox configuration locally and
 * try to register it.
 */
export async function createNewMailbox(
  wex: WalletExecutionContext,
  mailboxBaseUrl: string,
): Promise<MailboxConfiguration> {
  const keys = createEddsaKeyPair();
  const hpkeKey = encodeCrock(hpkeCreateSecretKey());
  const privKey = encodeCrock(keys.eddsaPriv);
  const nowInAYear = AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({
    years: 1}));
  const mailboxConf: MailboxConfiguration = {
    mailboxBaseUrl: mailboxBaseUrl,
    privateKey: privKey,
    privateEncryptionKey: hpkeKey,
    expiration: AbsoluteTime.toProtocolTimestamp(nowInAYear)
  };

  const resp = await registerMailbox(wex, mailboxConf);
  if (resp.status == "payment-required") {
    if (!resp.talerUri) {
      throw Error("payment required to register mailbox but no Taler URI given");
    }
    mailboxConf.payUri = succeedOrValue(TalerUris.fromString(resp.talerUri), undefined);
  }
  await wex.db.runReadWriteTx(
    {
      storeNames: ["mailboxConfigurations"],
    },
    async (tx) => {
      return await tx.mailboxConfigurations.put(mailboxConf);
    },
  );
  return mailboxConf;
}

function decryptTalerUriMessage(
  sk: Uint8Array,
  msg: Uint8Array,
) : string | undefined {
  const header = new Uint8Array(msg.slice(0, 4));
  const ct = new Uint8Array(msg.slice(4));
  const uri = hpkeOpenOneshot(sk,
                              stringToBytes("mailbox-message"),
                              header, // AAD
                              ct);
  if (!uri) {
    return undefined;
  }
  // Find start of padding
  const padIdx = uri.indexOf(0x00);
  if (-1 === padIdx) {
    return  new TextDecoder().decode(uri);
  }
  return new TextDecoder().decode(uri.slice(0, padIdx))
}

/**
 * Refresh mailbox through HTTP
 */
export async function refreshMailbox(
  wex: WalletExecutionContext,
  mailboxConf: MailboxConfiguration,
): Promise<MailboxMessageRecord[]> {
  const mailboxClient = new TalerMailboxInstanceHttpClient(mailboxConf.mailboxBaseUrl, wex.http);
  const privKey = decodeCrock(mailboxConf.privateKey);
  const pubKey = eddsaGetPublic(privKey);
  const hAddress = encodeCrock(sha512(pubKey));
  // Refresh message size
  var message_size;
  const resConf = await mailboxClient.getConfig();
  switch (resConf.case) {
    case "ok":
      message_size = resConf.body.message_body_bytes;
      break;
    default:
      throw Error("unable to get mailbox service config");
  }
  const res = await mailboxClient.getMessages({hMailbox: hAddress});
  switch (res.case) {
    case "ok":
      const hpkeSk: Uint8Array = decodeCrock(mailboxConf.privateEncryptionKey);
      if (res.body) {
        const messages = res.body.messages;
        const now = TalerProtocolTimestamp.now();
        if ((messages.byteLength % message_size) !== 0) {
          throw Error(`mailbox messages response not a multiple of message size! (${messages.byteLength} % ${message_size} != 0)`);
        }
        // FIXME: if we have reached the maximum number of
        // messages that the service returns at a time,
        // we probably have to call again until no more messages to
        // download.
        const numMessages = messages.byteLength / message_size;
        const records: MailboxMessageRecord[] = [];
        for (let i = 0; i < numMessages; i++) {
          const offset = i * message_size;
          const msg: Uint8Array = messages.slice(offset,
                                                 offset + message_size);
          const uri = decryptTalerUriMessage(hpkeSk,
                                             msg);
          if (!uri) {
            logger.warn(`unable to decrypt message number ${i}`);
            continue;
          }
          // Find start of padding
          const newMessage = {
            originMailboxBaseUrl: mailboxConf.mailboxBaseUrl,
            talerUri: uri,
            downloadedAt: now,
          };
          records.push(newMessage);
          await addMailboxMessage(wex, {message: newMessage});
        }
        // Message header: 8 byte + 64 byte SHA512 digest to sign
        // We hash all messages
        const messageBuffer = new ArrayBuffer(16);
        const vMsg = new DataView(messageBuffer);
        vMsg.setUint32(0, 4 * 4);
        vMsg.setUint32(4, TalerSignaturePurpose.MAILBOX_MESSAGES_DELETE);
        vMsg.setUint32(8, parseInt(res.body.etag));
        vMsg.setUint32(12, numMessages);
        const msgToSign: Uint8Array = new Uint8Array(messageBuffer);
        const privateSigningKey: Uint8Array = decodeCrock(mailboxConf.privateKey);
        const signature = eddsaSign(msgToSign, privateSigningKey);
        succeedOrThrow(await mailboxClient.deleteMessages({
          mailboxConf: mailboxConf,
          matchIf: res.body.etag,
          count: numMessages,
          signature: encodeCrock(signature),
        }));
        return records;
      }
      return []; // No new messages;
    default:
      throw Error("unexpected mailbox messages response empty");
  }
}

function encryptTalerUriMessage(
  encryptionKey: Uint8Array,
  talerUri: string,
  paddedMessageSize: number
) : Uint8Array {
  const headerBuf = new ArrayBuffer(4);
  // Padding must not include HPKE tag and encapsulation overhead
  // FIXME CRYPTO-AGILITY size of tag and encapsulation depends on used algos
  // DHKEM X25519: Encapsuation 32 bytes, Poly1305 tag 16 bytes
  const paddingLength = paddedMessageSize - 4 - talerUri.length - 16 - 32;
  const v = new DataView(headerBuf);
  v.setUint32(0, 0); // FIXME message type, derive number from crypto!
  const header = new Uint8Array(headerBuf);
  const padding = new Uint8Array(paddingLength).fill(0);
  const msg = new Uint8Array([...stringToBytes(talerUri), ...padding]);
  const encryptedMessage = hpkeSealOneshot(encryptionKey,
                                           stringToBytes("mailbox-message"),
                                           header,
                                           msg);
  return new Uint8Array([...header, ...encryptedMessage])
}

export async function sendTalerUriMessage(
  wex: WalletExecutionContext,
  req: SendTalerUriMailboxMessageRequest) : Promise<EmptyObject> {
  const mailboxClient = new TalerMailboxInstanceHttpClient(req.contact.mailboxBaseUri, wex.http);
  // Get message size
  var paddedMessageSize;
  const resConf = await mailboxClient.getConfig();
  switch (resConf.case) {
    case "ok":
      paddedMessageSize = resConf.body.message_body_bytes;
      break;
    default:
      return {};
  }
  const resKeys = await mailboxClient.getMailboxInfo(req.contact.mailboxAddress);
  var keys;
  switch (resKeys.case) {
    case "ok":
      keys = resKeys.body;
      break;
    default:
      throw Error("unable to get mailbox keys");
  }
  const encryptedMessage = encryptTalerUriMessage(decodeCrock(keys.encryptionKey),
                                                  req.talerUri,
                                                  paddedMessageSize);
  const resSend = await mailboxClient.sendMessage({
    h_address: req.contact.mailboxAddress,
    body: encryptedMessage,
  });
  switch (resSend.case) {
    case "ok":
      return {};
    default:
      throw Error("Failed to send message");
  }
}
