const APP_ID_PATTERN = /^[A-Za-z0-9_-]{5,32}$/;
const SESSION_PREFIX = "contractless:web3:";

export class ContractlessWalletNotInstalledError extends Error {
  constructor() {
    super(
      "Contractless Wallet is not installed. Install the browser wallet before continuing."
    );
    this.name = "ContractlessWalletNotInstalledError";
    this.code = "CONTRACTLESS_WALLET_NOT_INSTALLED";
  }
}

function randomHex(byteLength) {
  const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

function provider() {
  return window.contractless;
}

function websiteMessage(value, label) {
  const message = String(value ?? "").trim();
  if (!message || new TextEncoder().encode(message).length > 75) {
    throw new TypeError(`${label} must contain 1 to 75 bytes.`);
  }
  return message;
}

export function isContractlessWalletInstalled() {
  return Boolean(provider()?.isContractless && typeof provider()?.request === "function");
}

export async function waitForContractlessWallet(timeoutMs = 1_500) {
  const expiresAt = Date.now() + timeoutMs;
  while (Date.now() < expiresAt) {
    if (isContractlessWalletInstalled()) return true;
    await new Promise((resolve) => setTimeout(resolve, 25));
  }
  return isContractlessWalletInstalled();
}

export class ContractlessWallet {
  constructor({ appId, api = null }) {
    if (!APP_ID_PATTERN.test(String(appId ?? ""))) {
      throw new TypeError(
        "appId must contain 5 to 32 URL-safe characters and must never change for the application."
      );
    }
    this.appId = appId;
    this.api = api;
    this.storageKey = `${SESSION_PREFIX}${location.origin}:${appId}`;
    this.expirationTimer = undefined;
    this.scheduleStoredSessionExpiration();
  }

  isInstalled() {
    return isContractlessWalletInstalled();
  }

  session() {
    let stored;
    try {
      stored = sessionStorage.getItem(this.storageKey);
    } catch {
      return null;
    }
    if (!stored) return null;
    try {
      const session = JSON.parse(stored);
      if (
        session.origin !== location.origin
        || session.appId !== this.appId
        || typeof session.accessKey !== "string"
        || session.accessKey.length !== 64
        || !Number.isFinite(Number(session.sessionExpiresAt))
        || Number(session.sessionExpiresAt) <= Date.now()
      ) {
        this.clearSession({ expired: true });
        return null;
      }
      this.scheduleSessionExpiration(session);
      return session;
    } catch {
      this.clearSession();
      return null;
    }
  }

  async connect({ message, timestamp, nonce } = {}) {
    await this.requireProvider();
    const connectionMessage = websiteMessage(
      message ?? `Connect to ${location.origin}`,
      "Connection message"
    );
    const response = await provider().request({
      method: "contractless_connect",
      params: {
        origin: location.origin,
        appId: this.appId,
        message: connectionMessage,
        timestamp: Number(timestamp ?? Math.floor(Date.now() / 1_000)),
        nonce: String(nonce ?? randomHex(32))
      }
    });
    const session = {
      origin: location.origin,
      appId: this.appId,
      address: response.address,
      publicKey: response.publicKey,
      accessKey: response.accessKey,
      sessionExpiresAt: response.sessionExpiresAt
    };
    sessionStorage.setItem(this.storageKey, JSON.stringify(session));
    this.scheduleSessionExpiration(session);
    return {
      ...response,
      session
    };
  }

  async accounts() {
    const response = await this.authorizedRequest("contractless_accounts");
    const accounts = Array.isArray(response) ? response : [];
    if (accounts.length === 0) this.clearSession();
    return accounts;
  }

  async balances() {
    const response = await this.authorizedRequest("contractless_balances");
    return Array.isArray(response?.balances) ? response.balances : [];
  }

  async getBalance({ asset, nftSeries = 0 } = {}) {
    const requestedAsset = String(asset ?? "").trim().toUpperCase();
    const requestedSeries = Number(nftSeries);
    if (!requestedAsset) throw new TypeError("An asset name is required.");
    if (!Number.isInteger(requestedSeries) || requestedSeries < 0) {
      throw new TypeError("nftSeries must be a non-negative integer.");
    }
    const balance = (await this.balances()).find((entry) =>
      String(entry.asset ?? "").trim().toUpperCase() === requestedAsset
      && Number(entry.nft_series ?? 0) === requestedSeries
    );
    return balance ?? {
      asset: requestedAsset,
      nft_series: requestedSeries,
      balance_atomic: 0,
      balance: "0.00000000"
    };
  }

  async signMessage({ message, nonce, expiresAt } = {}) {
    const signedMessage = websiteMessage(message, "Signed message");
    const now = Math.floor(Date.now() / 1_000);
    return this.authorizedRequest("contractless_signMessage", {
      message: signedMessage,
      nonce: String(nonce ?? randomHex(32)),
      expires_at: Number(expiresAt ?? now + 300)
    });
  }

  async sendTransaction({ transaction } = {}) {
    if (!transaction || typeof transaction !== "object") {
      throw new TypeError("A complete transaction object is required.");
    }
    const txtype = Number(transaction.txtype);
    if (txtype === 6 || txtype === 7) {
      throw new TypeError(
        "Swaps and loan creation require a staged signing workflow."
      );
    }
    return this.authorizedRequest("contractless_sendTransaction", {
      transaction
    });
  }

  async signDualTransaction({
    transaction,
    signerSlot
  } = {}) {
    if (!transaction || typeof transaction !== "object") {
      throw new TypeError("A complete or partially signed transaction object is required.");
    }
    const txtype = Number(transaction.txtype
      ?? transaction.unsigned_swap?.txtype
      ?? transaction.unsigned_loan_contract?.txtype);
    if (txtype !== 6 && txtype !== 7) {
      throw new TypeError("Staged signing is only available for swaps and loan creation.");
    }
    if (signerSlot !== 1 && signerSlot !== 2) {
      throw new TypeError("signerSlot must be 1 or 2.");
    }
    return this.authorizedRequest("contractless_signDualTransaction", {
      transaction,
      signerSlot
    });
  }

  async completeDualTransaction({ transaction, signer1, signer2 } = {}) {
    if (!transaction || typeof transaction !== "object") {
      throw new TypeError("The original unsigned transaction is required.");
    }
    for (const [name, signer, slot] of [
      ["signer1", signer1, 1],
      ["signer2", signer2, 2]
    ]) {
      if (!signer || Number(signer.signerSlot) !== slot) {
        throw new TypeError(`${name} must contain the result for signer slot ${slot}.`);
      }
    }
    if (signer1.txid !== signer2.txid) {
      throw new Error("The two wallets signed different transaction hashes.");
    }
    if (signer1.unsignedBytes !== signer2.unsignedBytes) {
      throw new Error("The two wallets serialized different unsigned transaction bytes.");
    }
    return this.authorizedRequest("contractless_completeDualTransaction", {
      transaction,
      signature1: signer1.signature,
      publicKey1: signer1.signerPublicKey,
      signature2: signer2.signature,
      publicKey2: signer2.signerPublicKey
    });
  }

  async broadcastTransaction({ bytes, api = this.api } = {}) {
    if (typeof bytes !== "string" || !/^[0-9a-f]+$/i.test(bytes) || bytes.length % 2) {
      throw new TypeError("Complete hexadecimal transaction bytes are required.");
    }
    return this.apiRequest("/api/v1/transactions/broadcast", {
      method: "POST",
      body: { transaction_hex: bytes },
      api
    });
  }

  async apiRequest(path, {
    method = "GET",
    query = null,
    body = null,
    api = this.api
  } = {}) {
    const url = String(api?.url ?? "").replace(/\/+$/, "");
    const address = String(api?.address ?? "").trim().toLowerCase();
    const publicKey = String(api?.publicKey ?? "").trim().toLowerCase();
    const signature = String(api?.signature ?? "").trim().toLowerCase();
    if (!url || !address || !publicKey || !signature) {
      throw new TypeError(
        "API url, address, publicKey, and the precomputed 'aced' signature are required."
      );
    }
    const route = String(path ?? "");
    if (!route.startsWith("/api/v1")) {
      throw new TypeError("Contractless API routes must begin with /api/v1.");
    }
    const requestUrl = new URL(`${url}${route}`);
    if (query && typeof query === "object") {
      for (const [key, value] of Object.entries(query)) {
        if (value !== undefined && value !== null) {
          requestUrl.searchParams.set(key, String(value));
        }
      }
    }
    const headers = {
      "X-Contractless-Address": address,
      "X-Contractless-Public-Key": publicKey,
      "X-Contractless-Signature": signature
    };
    if (api.apiKey) headers["X-API-Key"] = String(api.apiKey);
    if (body !== null) headers["Content-Type"] = "application/json";
    const response = await fetch(requestUrl, {
      method: String(method).toUpperCase(),
      headers,
      body: body === null ? undefined : JSON.stringify(body)
    });
    const reply = await response.json().catch(() => undefined);
    if (!response.ok || reply?.success !== true) {
      throw new Error(reply?.error ?? `Contractless API request failed with HTTP ${response.status}.`);
    }
    return reply.data;
  }

  async disconnect() {
    const active = this.session();
    if (!active) return { disconnected: true };
    try {
      return await this.authorizedRequest(
        "contractless_disconnect",
        {},
        { reconnect: false }
      );
    } finally {
      this.clearSession();
    }
  }

  clearSession({ expired = false } = {}) {
    if (this.expirationTimer !== undefined) {
      clearTimeout(this.expirationTimer);
      this.expirationTimer = undefined;
    }
    try {
      sessionStorage.removeItem(this.storageKey);
    } catch {
      // The extension session still expires when the wallet locks.
    }
    if (expired) {
      window.dispatchEvent(new CustomEvent("contractless:sessionExpired", {
        detail: {
          origin: location.origin,
          appId: this.appId
        }
      }));
    }
  }

  scheduleStoredSessionExpiration() {
    let stored;
    try {
      stored = sessionStorage.getItem(this.storageKey);
    } catch {
      return;
    }
    if (!stored) return;
    try {
      this.scheduleSessionExpiration(JSON.parse(stored));
    } catch {
      this.clearSession();
    }
  }

  scheduleSessionExpiration(session) {
    if (this.expirationTimer !== undefined) {
      clearTimeout(this.expirationTimer);
      this.expirationTimer = undefined;
    }
    const expiresAt = Number(session?.sessionExpiresAt);
    if (!Number.isFinite(expiresAt)) return;
    const remaining = expiresAt - Date.now();
    if (remaining <= 0) {
      this.clearSession({ expired: true });
      return;
    }
    this.expirationTimer = setTimeout(() => {
      this.clearSession({ expired: true });
    }, remaining);
  }

  async requireProvider() {
    if (!await waitForContractlessWallet()) {
      throw new ContractlessWalletNotInstalledError();
    }
    return provider();
  }

  async authorizedRequest(method, params = {}, { reconnect = true } = {}) {
    await this.requireProvider();
    let session = this.session();
    if (!session && reconnect) {
      const connection = await this.connect({
        message: "Connect this application to continue"
      });
      session = connection.session;
    }
    if (!session) {
      throw new Error(
        "No active Contractless wallet session exists. Call connect() first."
      );
    }
    try {
      return await this.sendAuthorizedRequest(method, params, session);
    } catch (error) {
      if (/session is invalid|session.*expired|wallet is locked/i.test(String(error))) {
        this.clearSession();
        if (reconnect) {
          const connection = await this.connect({
            message: "Reconnect this application to continue"
          });
          return this.sendAuthorizedRequest(method, params, connection.session);
        }
      }
      throw error;
    }
  }

  async sendAuthorizedRequest(method, params, session) {
    return provider().request({
      method,
      params: {
        ...params,
        origin: location.origin,
        appId: this.appId,
        accessKey: session.accessKey
      }
    });
  }
}
