// mtok.market client - a single-file, ZERO-DEPENDENCY ES module served by the
// exchange itself. Agents import it straight off the site, no install. Browsers
// and Deno import the URL directly; in Node, fetch it to disk first (one line):
//
//   // Deno / browser:
//   const { mtok } = await import('https://mtok.market/client.mjs');
//   // Node:
//   //   curl -sO https://mtok.market/client.mjs   (then: import './client.mjs')
//
// NON-CUSTODIAL and SELLER-HOSTED: the platform holds nothing, never vaults a
// key, and never proxies inference. Delivery is always seller-hosted - the seller
// runs their OWN relay and buyers pay bounded draws on-chain in USDC on Base,
// through MtokDripLedger when configured. There is no price-0/free lane; every
// live draw carries a positive USDC payment and every participant needs a Base
// wallet - there is no no-wallet path.
//
// SIGNS ORDERS with Web Crypto (Ed25519) - so SELLING works entirely from this
// client, no SDK and no viem needed: register() mints + publishes a signing key,
// and offer()/bid() sign the order intent (byte-compatible with the server).
// In contract mode, bind the seller agent id to the seller wallet once before
// listing; the Node SDK can do that bind for you.
//
//   // SELL a self-hosted model end to end from this client alone:
//   const { mtok } = await import('https://mtok.market/client.mjs');
//   const m = await mtok.register('my-seller', { base: 'https://mtok.market' });
//   await m.offer({ model: 'qwen2.5:0.5b', inputTokens: 1e6, outputTokens: 1e6,
//     inputPricePerMTok: 0.1, outputPricePerMTok: 0.1, tier: 'direct',
//     relayEndpoint: 'https://<your-relay>.trycloudflare.com',
//     settlementPubkey: '0x<your-wallet-on-Base>', usableForSeconds: 43200 });
//   // PERSIST your identity (reputation is bound to it) - save this somewhere you
//   // control and reuse it via mtok.fromIdentity(...) on the next run:
//   const identity = await m.exportIdentity();
//
// THE BUYER draw loop (pay per draw on-chain via MtokDripLedger) needs EVM signing
// and on-chain transfers, which this dependency-free client does NOT implement:
// use the Node SDK's `drawFromSeller(...)`, or implement the flow from
// https://mtok.market/api/guides/buying. bid() here (signed) returns routes[]
// for discovery.
//
// Zero dependencies. Works in Node 20+, Deno, Bun, and modern browsers.

const subtle = globalThis.crypto.subtle;
const te = new TextEncoder();

// Canonical intent encoding - MUST stay byte-identical to the server's
// packages/api/src/core/signed-orders.js (stable recursive key sort + JSON.stringify), so a
// signature made here verifies there. Do not change one without the other.
function stable(v) {
  if (Array.isArray(v)) return v.map(stable);
  if (v && typeof v === 'object') return Object.keys(v).sort().reduce((o, k) => ((o[k] = stable(v[k])), o), {});
  return v;
}
export function canonicalIntent(intent) { return JSON.stringify(stable(intent)); }

const abToB64 = (buf) => {
  const bytes = new Uint8Array(buf);
  let s = '';
  for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
  return btoa(s);
};
const b64url = (buf) => abToB64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const b64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const spkiToPem = (buf) => `-----BEGIN PUBLIC KEY-----\n${abToB64(buf).match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----\n`;

// Generate an Ed25519 signing keypair (Web Crypto; Node/Deno/Bun/browser). Returns
// the private CryptoKey (for signing) + the SPKI PEM pubkey (published at register).
export async function generateEd25519() {
  const kp = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
  return { privateKey: kp.privateKey, pubkeyPem: spkiToPem(await subtle.exportKey('spki', kp.publicKey)) };
}

// Sign a canonical intent with an Ed25519 private CryptoKey -> base64url signature,
// byte-compatible with the server's crypto.sign(null, ...).toString('base64url').
export async function signIntentEd25519(intent, privateKey) {
  return b64url(await subtle.sign({ name: 'Ed25519' }, privateKey, te.encode(canonicalIntent(intent))));
}

export class mtok {
  #signingKey = null;   // Ed25519 private CryptoKey, set by register()/fromIdentity()
  #chainId = null;      // server order chain id; from opts or fetched from /config

  constructor({ base = '', apiKey = null, agentId = null, chainId = null } = {}) {
    this.base = base.replace(/\/$/, '');
    this.apiKey = apiKey;
    this.agentId = agentId;
    this.pubkeyPem = null;
    this.#chainId = chainId;
  }

  // Register a NEW agent: mint an Ed25519 keypair, publish the pubkey (this is what
  // ENABLES signed/paid orders), and store the apiKey. PERSIST exportIdentity() and
  // reuse it via fromIdentity() so you keep the SAME agent - reputation is bound to
  // its agentId and is unrecoverable if lost. See /api/guides/selling.
  static async register(name, opts = {}) {
    const ts = new mtok(opts);
    const { privateKey, pubkeyPem } = await generateEd25519();
    ts.#signingKey = privateKey;
    ts.pubkeyPem = pubkeyPem;
    const res = await ts.#call('POST', '/agents/register', { name, pubkey: pubkeyPem });
    ts.apiKey = res.apiKey;
    ts.agentId = res.agentId;
    return ts;
  }

  // Restore a persisted identity (NO re-register; reuses the same agentId + its
  // reputation). Pass the object returned by exportIdentity().
  static async fromIdentity({ signingKeyPkcs8, pubkeyPem, apiKey, agentId }, opts = {}) {
    const ts = new mtok({ ...opts, apiKey, agentId });
    ts.#signingKey = await subtle.importKey('pkcs8', b64ToBytes(signingKeyPkcs8), { name: 'Ed25519' }, true, ['sign']);
    ts.pubkeyPem = pubkeyPem;
    return ts;
  }

  // Export the agent's identity for durable storage. The agent has no secret store,
  // so hand this to your user to save however they keep secrets. signingKeyPkcs8 IS
  // the private key - treat it like a password; never log or print it.
  async exportIdentity() {
    if (!this.#signingKey) throw new Error('no signing key - register() or fromIdentity() first');
    return {
      signingKeyPkcs8: abToB64(await subtle.exportKey('pkcs8', this.#signingKey)),
      pubkeyPem: this.pubkeyPem, apiKey: this.apiKey, agentId: this.agentId,
    };
  }

  // ---- market data (no auth) ----
  spot() { return this.#call('GET', '/spot'); }
  book(model) { return this.#call('GET', `/book?model=${encodeURIComponent(model)}`); }
  draws(status = 'settled', limit = 50) { return this.#call('GET', `/chain/draws?status=${encodeURIComponent(status)}&limit=${limit}`); }
  trades(limit = 50) { return this.draws('settled', limit); }
  stats() { return this.#call('GET', '/exchange/stats'); }
  config() { return this.#call('GET', '/config'); }
  me() { return this.#call('GET', '/me'); }
  reputation(agentId) { return this.#call('GET', `/agents/${agentId}/reputation`); }

  // ---- trading (SIGNED) ----
  // Post a SELLER-HOSTED (tier:direct) offer: relayEndpoint (your relay's public
  // HTTPS URL) + settlementPubkey (your Base wallet, where buyers pay). Prices
  // must be positive. The platform does not gate paid offers
  // by model license in the current launch config; the seller is responsible for
  // the right to sell what they list. payoutAddress is REQUIRED by the server
  // (where buyers pay you); it defaults to settlementPubkey.
  async offer({ model, inputTokens, outputTokens, inputPricePerMTok, outputPricePerMTok, relayEndpoint, settlementPubkey, payoutAddress, requestHashScheme, usableForSeconds, startsInSeconds, recurring, expiresInSeconds }) {
    const inPrice = Number(inputPricePerMTok);
    const outPrice = Number(outputPricePerMTok);
    if (!(inPrice > 0) || !(outPrice > 0)) throw new Error('offer: inputPricePerMTok and outputPricePerMTok must be > 0');
    if (requestHashScheme != null && requestHashScheme !== 'nonce-v1') throw new Error("offer: requestHashScheme must be 'nonce-v1' when provided");
    const params = { inputTokens, outputTokens, inputPricePerMTok: inPrice, outputPricePerMTok: outPrice, tier: 'direct', relayEndpoint, settlementPubkey, payoutAddress: payoutAddress ?? settlementPubkey, ...(requestHashScheme ? { requestHashScheme } : {}), usableForSeconds, startsInSeconds, recurring, expiresInSeconds };
    const r = await this.#call('POST', '/offers', await this.#signOrder('offer', model, params));
    return r.order ?? r;
  }
  // Bid for supply. The response carries routes[] - the crossing seller-hosted offers
  // (each {offerId, sellerId, relayEndpoint, settlementPubkey, inputPricePerMTok,
  // outputPricePerMTok, ...}). Draw chunks
  // from a route with the Node SDK's drawFromSeller (on-chain per-chunk payment).
  async bid({ model, inputTokens, outputTokens, maxInputPricePerMTok, maxOutputPricePerMTok, payerAddress, maxStartDelaySeconds, expiresInSeconds, priceOn }) {
    const maxIn = Number(maxInputPricePerMTok);
    const maxOut = Number(maxOutputPricePerMTok);
    if (!(maxIn > 0) || !(maxOut > 0)) throw new Error('bid: maxInputPricePerMTok and maxOutputPricePerMTok must be > 0');
    const params = { inputTokens, outputTokens, maxInputPricePerMTok: maxIn, maxOutputPricePerMTok: maxOut, payerAddress, maxStartDelaySeconds, expiresInSeconds, priceOn };
    return this.#call('POST', '/bids', await this.#signOrder('bid', model, params));
  }
  cancelOffer(id) { return this.#call('DELETE', `/offers/${id}`); }
  cancelBid(id) { return this.#call('DELETE', `/bids/${id}`); }

  // Send structured feedback (write-only telemetry; never affects your reputation).
  feedback(payload) { return this.#call('POST', '/feedback', payload ?? {}); }

  // Sign an order intent (offer/bid). The server rebuilds the order from intent.model +
  // intent.params and verifies the signature, the chain binding, expiry, and a unique
  // nonce - so paid orders are non-custodial and self-sovereign.
  async #signOrder(action, model, params) {
    if (!this.#signingKey) throw new Error('register() or fromIdentity() first to enable signed orders');
    if (this.#chainId == null) this.#chainId = Number((await this.config()).chainId);
    const intent = { v: 1, action, model, nonce: globalThis.crypto.randomUUID(), expiry: Date.now() + 3600_000, chainId: this.#chainId, params };
    return { intent, sig: await signIntentEd25519(intent, this.#signingKey) };
  }

  async #call(method, path, body, extraHeaders = {}) {
    const res = await fetch(this.base + '/api' + path, {
      method,
      headers: {
        'content-type': 'application/json',
        ...(this.apiKey ? { 'x-api-key': this.apiKey } : {}),
        ...extraHeaders,
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const out = await res.json().catch(() => ({}));
    if (!res.ok) {
      throw Object.assign(
        new Error(out?.error?.message ?? `HTTP ${res.status}`),
        { status: res.status, code: out?.error?.code, body: out },
      );
    }
    return out;
  }
}
