function bufferFromBase64(base64) {
  const binaryString = atob(base64);
  const length = binaryString.length;
  const bytes = new Uint8Array(length);

  for (let i = 0; i < length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }

  return bytes;
}

function fromString(str, encoding = "utf8") {
  if (encoding === "utf8") {
    return new TextEncoder().encode(str);
  } else if (encoding === "base16") {
    if (str.length % 2 !== 0) {
      throw new Error("Invalid hex string length.");
    }
    let bytes = new Uint8Array(str.length / 2);
    for (let i = 0; i < str.length; i += 2) {
      bytes[i / 2] = parseInt(str.substring(i, i + 2), 16);
    }
    return bytes;
  } else if (encoding === "base64url") {
    str = str.replace(/-/g, "+").replace(/_/g, "/");
    while (str.length % 4) {
      str += "=";
    }
    return new Uint8Array(bufferFromBase64(str));
  } else {
    throw new Error(`Unsupported encoding "${encoding}"`);
  }
}

/**
 * Convert a Uint8Array to a string with the given encoding.
 *
 * @param {Uint8Array} byteArray - The Uint8Array to convert.
 * @param {string} [encoding='utf8'] - The desired encoding ('utf8', 'base16', 'base64url').
 * @returns {string} - The encoded string.
 * @throws {Error} - Throws an error if the encoding is unsupported.
 */
function toString(byteArray, encoding = "utf8") {
  switch (encoding) {
    case "utf8":
      return decodeUTF8(byteArray);
    case "base16":
      return toBase16(byteArray);
    case "base64url":
      return toBase64Url(byteArray);
    default:
      throw new Error(`Unsupported encoding "${encoding}"`);
  }
}

/**
 * Decode a Uint8Array as a UTF-8 string.
 *
 * @param {Uint8Array} byteArray
 * @returns {string}
 */
function decodeUTF8(byteArray) {
  return new TextDecoder().decode(byteArray);
}

/**
 * Convert a Uint8Array to a base16 (hex) encoded string.
 *
 * @param {Uint8Array} byteArray
 * @returns {string}
 */
function toBase16(byteArray) {
  return Array.from(byteArray)
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

/**
 * Convert a Uint8Array to a base64url encoded string.
 *
 * @param {Uint8Array} byteArray
 * @returns {string}
 */
function toBase64Url(byteArray) {
  let uint8Array = new Uint8Array(byteArray);
  let binaryString = "";
  for (let i = 0; i < uint8Array.length; i++) {
    binaryString += String.fromCharCode(uint8Array[i]);
  }

  // Encode to base64
  let base64 = btoa(binaryString);

  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

const u8a = { toString, fromString };

function sha256(payload) {
  const data = typeof payload === "string" ? u8a.fromString(payload) : payload;
  return nobleHashes.sha256(data);
}

async function accessToken(identifier) {
  const did = identifier["did"];
  const privateKeyHex = identifier["keys"][0]["privateKeyHex"];

  const signer = await SimpleSigner(privateKeyHex);

  const nowEpoch = Math.floor(Date.now() / 1000);
  const endEpoch = nowEpoch + 60; // add one minute

  const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
  const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
  const jwt = await createJWT(tokenPayload, {
    alg,
    issuer: did,
    signer,
  });
  return jwt;
}

async function createJWT(payload, options, header = {}) {
  const { issuer, signer, alg, expiresIn, canonicalize } = options;

  if (!signer)
    throw new Error(
      "missing_signer: No Signer functionality has been configured",
    );
  if (!issuer)
    throw new Error("missing_issuer: No issuing DID has been configured");
  if (!header.typ) header.typ = "JWT";
  if (!header.alg) header.alg = alg;

  const timestamps = {
    iat: Math.floor(Date.now() / 1000),
    exp: undefined,
  };

  if (expiresIn) {
    if (typeof expiresIn === "number") {
      timestamps.exp = (payload.nbf || timestamps.iat) + Math.floor(expiresIn);
    } else {
      throw new Error("invalid_argument: JWT expiresIn is not a number");
    }
  }

  const fullPayload = { ...timestamps, ...payload, iss: issuer };
  return createJWS(fullPayload, signer, header, { canonicalize });
}

const defaultAlg = "ES256K";

async function createJWS(payload, signer, header = {}, options = {}) {
  if (!header.alg) header.alg = defaultAlg;
  const encodedPayload =
    typeof payload === "string"
      ? payload
      : encodeSection(payload, options.canonicalize);
  const signingInput = [
    encodeSection(header, options.canonicalize),
    encodedPayload,
  ].join(".");

  const jwtSigner = ES256KSignerAlg(false);
  const signature = await jwtSigner(signingInput, signer);

  // JWS Compact Serialization
  // https://www.rfc-editor.org/rfc/rfc7515#section-7.1
  return [signingInput, signature].join(".");
}

function canonicalizeData(object) {
  if (typeof object === "number" && isNaN(object)) {
    throw new Error("NaN is not allowed");
  }

  if (typeof object === "number" && !isFinite(object)) {
    throw new Error("Infinity is not allowed");
  }

  if (object === null || typeof object !== "object") {
    return JSON.stringify(object);
  }

  if (object.toJSON instanceof Function) {
    return serialize(object.toJSON());
  }

  if (Array.isArray(object)) {
    const values = object.reduce((t, cv, ci) => {
      const comma = ci === 0 ? "" : ",";
      const value = cv === undefined || typeof cv === "symbol" ? null : cv;
      return `${t}${comma}${serialize(value)}`;
    }, "");
    return `[${values}]`;
  }

  const values = Object.keys(object)
    .sort()
    .reduce((t, cv) => {
      if (object[cv] === undefined || typeof object[cv] === "symbol") {
        return t;
      }
      const comma = t.length === 0 ? "" : ",";
      return `${t}${comma}${serialize(cv)}:${serialize(object[cv])}`;
    }, "");
  return `{${values}}`;
}

function encodeSection(data, shouldCanonicalize = false) {
  if (shouldCanonicalize) {
    return encodeBase64url(canonicalizeData(data));
  } else {
    return encodeBase64url(JSON.stringify(data));
  }
}

function encodeBase64url(s) {
  return bytesToBase64url(u8a.fromString(s));
}

function instanceOfEcdsaSignature(object) {
  return typeof object === "object" && "r" in object && "s" in object;
}

function ES256KSignerAlg(recoverable) {
  return async function sign(payload, signer) {
    const signature = await signer(payload);
    if (instanceOfEcdsaSignature(signature)) {
      return toJose(signature, recoverable);
    } else {
      if (
        recoverable &&
        typeof fromJose(signature).recoveryParam === "undefined"
      ) {
        throw new Error(
          `not_supported: ES256K-R not supported when signer doesn't provide a recovery param`,
        );
      }
      return signature;
    }
  };
}

function leftpad(data, size = 64) {
  if (data.length === size) return data;
  return "0".repeat(size - data.length) + data;
}

async function SimpleSigner(hexPrivateKey) {
  const signer = await ES256KSigner(hexToBytes(hexPrivateKey), true);
  return async (data) => {
    const signature = await signer(data);
    return fromJose(signature);
  };
}

function hexToBytes(s, minLength) {
  let input = s.startsWith("0x") ? s.substring(2) : s;

  if (input.length % 2 !== 0) {
    input = `0${input}`;
  }

  if (minLength) {
    const paddedLength = Math.max(input.length, minLength * 2);
    input = input.padStart(paddedLength, "00");
  }

  return u8a.fromString(input.toLowerCase(), "base16");
}

function ES256KSigner(privateKey, recoverable = false) {
  const privateKeyBytes = privateKey;
  if (privateKeyBytes.length !== 32) {
    throw new Error(
      `bad_key: Invalid private key format. Expecting 32 bytes, but got ${privateKeyBytes.length}`,
    );
  }

  return async function (data) {
    const signature = nobleCurves.secp256k1.sign(sha256(data), privateKeyBytes);
    return toJose(
      {
        r: leftpad(signature.r.toString(16)),
        s: leftpad(signature.s.toString(16)),
        recoveryParam: signature.recovery,
      },
      recoverable,
    );
  };
}

function toJose(signature, recoverable) {
  const { r, s, recoveryParam } = signature;
  const jose = new Uint8Array(recoverable ? 65 : 64);
  jose.set(u8a.fromString(r, "base16"), 0);
  jose.set(u8a.fromString(s, "base16"), 32);

  if (recoverable) {
    if (typeof recoveryParam === "undefined") {
      throw new Error("Signer did not return a recoveryParam");
    }
    jose[64] = recoveryParam;
  }
  return bytesToBase64url(jose);
}

function bytesToBase64url(b) {
  return u8a.toString(b, "base64url");
}

function base64ToBytes(s) {
  const inputBase64Url = s
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");
  return u8a.fromString(inputBase64Url, "base64url");
}

function bytesToHex(b) {
  return u8a.toString(b, "base16");
}

function fromJose(signature) {
  const signatureBytes = base64ToBytes(signature);
  if (signatureBytes.length < 64 || signatureBytes.length > 65) {
    throw new TypeError(
      `Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
    );
  }
  const r = bytesToHex(signatureBytes.slice(0, 32));
  const s = bytesToHex(signatureBytes.slice(32, 64));
  const recoveryParam =
    signatureBytes.length === 65 ? signatureBytes[64] : undefined;

  return { r, s, recoveryParam };
}

function validateBase64(s) {
  if (
    !/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test(
      s,
    )
  ) {
    throw new TypeError("invalid encoding");
  }
}

function decodeBase64(s) {
  validateBase64(s);
  var i,
    d = atob(s),
    b = new Uint8Array(d.length);
  for (i = 0; i < d.length; i++) b[i] = d.charCodeAt(i);
  return b;
}

async function getSettingById(id) {
  return new Promise((resolve, reject) => {
    let openRequest = indexedDB.open("TimeSafari");

    openRequest.onupgradeneeded = (event) => {
      // Handle database setup if necessary
      let db = event.target.result;
      if (!db.objectStoreNames.contains("settings")) {
        db.createObjectStore("settings", { keyPath: "id" });
      }
    };

    openRequest.onsuccess = (event) => {
      let db = event.target.result;
      let transaction = db.transaction("settings", "readonly");
      let objectStore = transaction.objectStore("settings");
      let getRequest = objectStore.get(id);

      getRequest.onsuccess = () => resolve(getRequest.result);
      getRequest.onerror = () => reject(getRequest.error);
    };

    openRequest.onerror = () => reject(openRequest.error);
  });
}

async function setMostRecentNotified(id) {
  try {
    const db = await openIndexedDB("TimeSafari");
    const transaction = db.transaction("settings", "readwrite");
    const store = transaction.objectStore("settings");
    const data = await getRecord(store, 1);

    if (data) {
      data["lastNotifiedClaimId"] = id;
      await updateRecord(store, data);
    } else {
      console.error(
        "safari-notifications setMostRecentNotified IndexedDB settings record not found",
      );
    }

    transaction.oncomplete = () => db.close();
  } catch (error) {
    console.error(
      "safari-notifications setMostRecentNotified IndexedDB error",
      error,
    );
  }
}

async function appendDailyLog(message) {
  try {
    const db = await openIndexedDB("TimeSafari");
    const transaction = db.transaction("logs", "readwrite");
    const store = transaction.objectStore("logs");
    // only keep one day's worth of logs
    const todayKey = new Date().toDateString();
    const previous = await getRecord(store, todayKey);
    if (!previous) {
      await store.clear(); // clear out everything previous when this is today's first log
    }
    let fullMessage = (previous && previous.message) || "";
    if (fullMessage) {
      fullMessage += "\n";
    }
    fullMessage += message;
    await updateRecord(store, { date: todayKey, message: fullMessage });
    transaction.oncomplete = () => db.close();
    return true;
  } catch (error) {
    console.error("safari-notifications logMessage IndexedDB error", error);
    return false;
  }
}

function openIndexedDB(dbName) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(dbName);
    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);
  });
}

function getRecord(store, key) {
  return new Promise((resolve, reject) => {
    const request = store.get(key);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Note that this assumes there is only one record in the store.
function updateRecord(store, data) {
  return new Promise((resolve, reject) => {
    const request = store.put(data);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function fetchAllAccounts() {
  return new Promise((resolve, reject) => {
    const openRequest = indexedDB.open("TimeSafariAccounts");

    openRequest.onupgradeneeded = function (event) {
      const db = event.target.result;
      if (!db.objectStoreNames.contains("accounts")) {
        db.createObjectStore("accounts", { keyPath: "id" });
      }
    };

    openRequest.onsuccess = function (event) {
      const db = event.target.result;
      const transaction = db.transaction("accounts", "readonly");
      const objectStore = transaction.objectStore("accounts");
      const getAllRequest = objectStore.getAll();

      getAllRequest.onsuccess = function () {
        resolve(getAllRequest.result);
      };
      getAllRequest.onerror = function () {
        reject(getAllRequest.error);
      };
    };

    openRequest.onerror = function () {
      reject(openRequest.error);
    };
  });
}

async function getNotificationCount() {
  let accounts = [];
  let result = null;
  // 1 is our master settings ID; see MASTER_SETTINGS_KEY
  const settings = await getSettingById(1);
  let lastNotifiedClaimId = null;
  if ("lastNotifiedClaimId" in settings) {
    lastNotifiedClaimId = settings["lastNotifiedClaimId"];
  }
  const activeDid = settings["activeDid"];
  accounts = await fetchAllAccounts();
  let activeAccount = null;
  for (let i = 0; i < accounts.length; i++) {
    if (accounts[i]["did"] == activeDid) {
      activeAccount = accounts[i];
      break;
    }
  }

  const headers = {
    "Content-Type": "application/json",
  };

  const identity = activeAccount && activeAccount["identity"];
  if (identity && "secret" in self) {
    const secret = self.secret;
    const secretUint8Array = self.decodeBase64(secret);
    const messageWithNonceAsUint8Array = self.decodeBase64(identity);
    const nonce = messageWithNonceAsUint8Array.slice(0, 24);
    const message = messageWithNonceAsUint8Array.slice(24, identity.length);
    const decoder = new TextDecoder("utf-8");
    const decrypted = self.secretbox.open(message, nonce, secretUint8Array);
    const msg = decoder.decode(decrypted);
    const identifier = JSON.parse(JSON.parse(msg));

    headers["Authorization"] = "Bearer " + (await accessToken(identifier));
  }

  const response = await fetch(
    settings["apiServer"] + "/api/v2/report/claims",
    {
      method: "GET",
      headers: headers,
    },
  );
  if (response.status == 200) {
    const json = await response.json();
    const claims = json["data"];
    let newClaims = 0;
    for (let i = 0; i < claims.length; i++) {
      const claim = claims[i];
      if (claim["id"] === lastNotifiedClaimId) {
        break;
      }
      newClaims++;
    }
    if (newClaims > 0) {
      result = `There are ${newClaims} new activities on Time Safari`;
    }
    const most_recent_notified = claims[0]["id"];
    await setMostRecentNotified(most_recent_notified);
  } else {
    console.error(
      "safari-notifications getNotificationsCount got a bad response status when fetching claims",
      response.status,
      response,
    );
  }

  return result;
}

self.appendDailyLog = appendDailyLog;
self.getNotificationCount = getNotificationCount;
self.decodeBase64 = decodeBase64;