/*
 This file is part of GNU Taler
 (C) 2026 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU Affero 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 Affero General Public License for more details.

 You should have received a copy of the GNU Affero General Public License along with
 GNU Anastasis; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

// @ts-check

const website = atob(window.location.hash.substring(1));
const encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
// cap at 100 years, we don't deal well with 'forever' otherwise
const usePickupDelay = Math.min(MAX_PICKUP_DELAY, 60 * 60 * 24 * 365 * 100);

// Strip trailing slash so we can append /templateId cleanly.
const merchantBase = MERCHANT_BACKEND.replace(/\/$/, "");
const merchantUrl = new URL(merchantBase);
const merchantProto = merchantUrl.protocol;
const suffix = merchantProto === "http:" ? "+http" : "";
const merchantHost = merchantUrl.host;
const merchantPath = merchantUrl.pathname;
const expTime = Math.floor(Date.now() / 1000) + usePickupDelay;

/**
 * @param {ArrayBuffer} data
 * @returns {string}
 */
function encodeCrock(data) {
  const dataBytes = new Uint8Array(data);
  let sb = "";
  const size = data.byteLength;
  let bitBuf = 0;
  let numBits = 0;
  let pos = 0;
  while (pos < size || numBits > 0) {
    if (pos < size && numBits < 5) {
      const d = dataBytes[pos++];
      bitBuf = (bitBuf << 8) | d;
      numBits += 8;
    }
    if (numBits < 5) {
      bitBuf = bitBuf << (5 - numBits);
      numBits = 5;
    }
    const v = (bitBuf >>> (numBits - 5)) & 31;
    sb += encTable[v];
    numBits -= 5;
  }
  return sb;
}

/**
 * @param {number} sec
 * @returns {Uint8Array}
 */
function timestampRoundedToBuffer(sec) {
  const b = new ArrayBuffer(8);
  const v = new DataView(b);
  const numVal = BigInt(sec) * 1000n * 1000n;
  // The buffer we sign over represents the timestamp in microseconds.
  v.setBigUint64(0, numVal);
  return new Uint8Array(b);
}

/**
 * @param {number} ms
 * @returns {Promise<void>}
 */
function waitMs(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Encode @a bytes in the RFC 4648 section 5 URL-safe alphabet, with
 * the padding stripped -- the same form the daemon produces with
 * GNUNET_STRINGS_base64url_encode(), and the mirror of the decode at
 * the top of this file.
 *
 * Deliberately built out of btoa() rather than the much tidier
 * `bytes.toBase64({alphabet: "base64url"})'.  That method is a 2024-25
 * addition (Firefox 133, Safari 18.2, Chrome 140), so on anything
 * older it is simply not a function -- and this runs on the payment
 * path, where the exception would leave a paywall that cannot be paid
 * with no indication why.  That is the same failure the decode above
 * exists to prevent, and it is not worth reintroducing at the other
 * end for the sake of one line.
 *
 * @param {Uint8Array} bytes
 * @returns {string}
 */
function base64url(bytes) {
  let s = "";

  /* Not String.fromCharCode(...bytes): spreading a large array
     overflows the argument limit.  The digests here are 32 bytes, but
     the loop costs nothing and does not have to be re-examined if a
     caller ever passes something bigger. */
  for (const b of bytes) {
    s += String.fromCharCode(b);
  }
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

/**
 * @param {BufferSource} data
 * @returns {Promise<string>}
 */
async function sha256b64(data) {
  const buf = await crypto.subtle.digest("SHA-256", data);
  return base64url(new Uint8Array(buf));
}

/**
 * @param {number} curTime
 * @param {Uint8Array} nonceBuf
 * @param {string} website
 * @returns {Promise<string>}
 */
async function makePaivanaId(curTime, nonceBuf, website) {
  const websiteBuf = new TextEncoder().encode(`${website}\0`);
  const curTimeBuf = timestampRoundedToBuffer(curTime);

  const length = nonceBuf.length + websiteBuf.length + curTimeBuf.length;
  const buf = new Uint8Array(length);
  buf.set(nonceBuf, 0);
  buf.set(websiteBuf, nonceBuf.length);
  buf.set(curTimeBuf, nonceBuf.length + websiteBuf.length);
  const hash = await sha256b64(buf);
  return `${curTime}-${hash}`;
}

/**
 * @param {string} order_id
 * @param {HTMLElement} linkEl
 * @param {HTMLElement} errorEl
 * @param {string} nonce
 * @returns {Promise<void>}
 */
async function confirmPayment(order_id, linkEl, errorEl, nonce) {
  linkEl.textContent = I18N_PAYMENT_CONFIRMED_LOADING;
  // @ts-ignore
  linkEl.href = "#";
  try {
    const res = await fetch(`${window.location.origin}/.well-known/paivana`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      redirect: "manual",
      body: JSON.stringify({
        order_id,
        nonce,
        expiration: { t_s: expTime },
        website,
      }),
    });
    if (res.status >= 400) {
      linkEl.textContent = I18N_PAYMENT_CONFIRMED_PROBLEM;
      errorEl.textContent = JSON.stringify(await res.json());
      return;
    }
    const dest = res.redirected ? res.url : website;
    location.href = dest;
  } catch (e) {
    console.warn("[paivana] Error trying to confirm payment:", e);
    errorEl.textContent = I18N_PAYMENT_CONFIRMED_ERROR;
  }
}

/**
 * @param {HTMLElement} el
 * @returns {void}
 */
function toggleDescription(el) {
  for (const a of el.getElementsByClassName("arrow")) {
    a.classList.toggle("upside-down");
  }
  for (const a of el.getElementsByClassName("price-list")) {
    a.classList.toggle("hidden");
  }
}

async function main() {
  const nonceBuf = new Uint8Array(16);
  crypto.getRandomValues(nonceBuf);
  const nonce = encodeCrock(nonceBuf.buffer);

  const paivanaId = await makePaivanaId(expTime, nonceBuf, website);

  // finally we can compute the talerURI and polling URL
  const TALER_URI = [
    `taler${suffix}://pay-template/`,
    merchantHost,
    merchantPath,
    `/`,
    MERCHANT_TEMPLATE_ID,
    `?session_id=${encodeURIComponent(paivanaId)}`,
    `&fulfillment_url=${encodeURIComponent(website)}`,
  ].join("");

  const PAIVANA_POLL_URL = [
    merchantBase,
    "/sessions/",
    encodeURIComponent(paivanaId),
    "?fulfillment_url=",
    encodeURIComponent(website),
    "&timeout_ms=",
    POLL_WAIT_MS,
  ].join("");

  // grab all the html element we need
  const talerLink = document.getElementById("taler-link");
  const errorMessageLabel = document.getElementById("error-message");
  const qrDiv = document.getElementById("qrcode");

  if (talerLink) {
    // show the taler URI as a link
    // because we want the user to be able to use the webex
    // @ts-ignore
    talerLink.href = TALER_URI;
  }

  // show the qr code
  new QRCode(qrDiv, {
    text: TALER_URI,
    width: QR_WIDTH,
    height: QR_HEIGHT,
    correctLevel: QRCode.CorrectLevel.M,
  });

  // From here we just poll. Whe the request from polling
  // returns that the order has been paid we show to the
  // proxy that we hold the nonce and it should return
  // us a valid cookie.
  while (true) {
    const start = performance.now();
    try {
      const res = await fetch(PAIVANA_POLL_URL, { cache: "no-store" });
      if (res.status === 200) {
        let info = null;
        try {
          info = await res.json();
        } catch (_) {}
        console.log("[paivana] Got reponse from backend", res, info);
        if (info && info.order_id) {
          if (talerLink && errorMessageLabel) {
            await confirmPayment(
              info.order_id,
              talerLink,
              errorMessageLabel,
              nonce,
            );
          }
        } else {
          if (talerLink) {
            talerLink.textContent = I18N_PAYMENT_CONFIRMED_NO_ORDER;
          }
          const remMs = Math.round(POLL_WAIT_MS - (performance.now() - start));
          if (remMs > 0) await waitMs(remMs);
          location.href = website;
        }
        return;
      }
    } catch (e) {
      console.warn("[paivana] poll error:", e);
      if (talerLink) {
        // @ts-ignore
        talerLink.href = "#";
        talerLink.textContent = I18N_PAYMENT_NETWORK_PROBLEM;
      }
    }
    const remMs = Math.round(POLL_WAIT_MS - (performance.now() - start));
    if (remMs > 0) await waitMs(remMs);
  }
}
