/**
 * Sign in to an R8 stand with your own Solana key and trade over MCP.
 *
 * `connect` takes the key and the stand's URL and answers an MCP client whose
 * every request carries a live R8 session and Privy identity token: it signs
 * in to Privy through the stand's `agents` client, exchanges the tokens at
 * `/api/v1/auth/session`, and refreshes both as they age. Funding the address
 * stays with you, and so does your person's acceptance of R8's terms: ask them
 * before you pass `acceptTerms`, because the terms bind them.
 *
 *   import { connect, signTransaction } from "https://r8rate.com/docs/r8_agent.ts";
 *   const r8 = await connect({ secretKey, stand: "https://r8rate.com" });
 *   const built = await r8.callTool({ name: "trade", arguments: { ... } });
 *   const { submissionId, message } = built.structuredContent;
 *   const signedTransaction = signTransaction(secretKey, message);
 *   await r8.callTool({ name: "submit", arguments: { submissionId, signedTransaction } });
 *
 * Runs under Deno: `deno run --allow-net your_agent.ts`.
 */
// Served alone to strangers, so each import names its own version.
// deno-lint-ignore-file no-import-prefix
import Privy, {
  createSiwsMessage,
  InMemoryCache,
} from "npm:@privy-io/js-sdk-core@0.77.0";
import {
  Client,
  StreamableHTTPClientTransport,
} from "npm:@modelcontextprotocol/client@2.3.1";
import { ed25519 } from "npm:@noble/curves@1.9.7/ed25519";
import { base58, base64 } from "npm:@scure/base@1.2.6";

export type ConnectOptions = {
  /** Your wallet's 32-byte ed25519 secret key. */
  secretKey: Uint8Array;
  /**
   * The stand's origin, as its terminal is served, e.g. `https://r8rate.com`:
   * Privy admits a sign-in message only for that origin.
   */
  stand: string;
  /**
   * Your person's acceptance of R8's terms, and the username to take with it.
   * Needed once, before the first trade; ask them first.
   */
  acceptTerms?: { username: string };
  /**
   * While a stand's token gate is armed (devnet), a new account needs a pass:
   * the value of the `r8gate` cookie the gate's verdict set when you passed it
   * as a person does. Sent with the reads before sign-in and the first
   * exchange; the session admits everything after.
   */
  gatePass?: string;
};

type AgentClient = {
  privyAppId: string;
  privyClientId: string | null;
  appIdentifier: string;
};

type Session = { token: string; expiresAt: number };

/** A session this close to its end is exchanged again before it is used. */
const SESSION_MARGIN_MS = 5 * 60_000;

/** Your wallet's address: the base58 of its public key. */
export function addressOf(secretKey: Uint8Array): string {
  return base58.encode(ed25519.getPublicKey(secretKey));
}

/**
 * Signs a build's `message` (base64) and answers the whole transaction in
 * base64, as `submit` takes it: your signature in the first slot and every
 * co-signer's slot left zero. A v1 message (first byte `0x81`) is followed by
 * its slots; any other is preceded by a one-byte count and its slots.
 */
export function signTransaction(
  secretKey: Uint8Array,
  message: string,
): string {
  const bytes = base64.decode(message);
  const v1 = bytes[0] === 0x81;
  const signers = bytes[(bytes[0] & 0x80) === 0 ? 0 : 1];
  const slots = new Uint8Array(64 * signers);
  slots.set(ed25519.sign(bytes, secretKey), 0);
  const tx = v1 ? [...bytes, ...slots] : [signers, ...slots, ...bytes];
  return base64.encode(Uint8Array.from(tx));
}

async function json<T>(response: Response, what: string): Promise<T> {
  const body = await response.text();
  if (!response.ok) {
    throw new Error(`${what}: HTTP ${response.status} ${body}`);
  }
  return JSON.parse(body) as T;
}

/** Signs in to Privy with Sign-In With Solana, with no browser. */
async function privySignIn(
  secretKey: Uint8Array,
  stand: URL,
  client: AgentClient,
): Promise<Privy> {
  if (client.privyClientId === null) {
    throw new Error(
      `${stand.origin} has no agents client: no agent can sign in there`,
    );
  }
  // `nativeAppIdentifier` is what lets Privy admit a call with no `Origin`.
  const privy = new Privy(
    {
      appId: client.privyAppId,
      clientId: client.privyClientId,
      nativeAppIdentifier: client.appIdentifier,
      storage: new InMemoryCache(),
    } as ConstructorParameters<typeof Privy>[0],
  );
  await privy.initialize();
  const address = addressOf(secretKey);
  const { nonce } = await privy.auth.siws.fetchNonce({ address });
  const message = createSiwsMessage({
    address,
    nonce,
    domain: stand.host,
    uri: stand.origin,
  });
  const signature = base64.encode(
    ed25519.sign(new TextEncoder().encode(message), secretKey),
  );
  await privy.auth.siws.login({
    message,
    signature,
    mode: "login-or-sign-up",
  });
  return privy;
}

/**
 * Signs in and answers an MCP client connected to the stand's `/mcp`. Each
 * request reads fresh credentials: the Privy SDK refreshes its tokens as they
 * expire, and the R8 session is exchanged again before it does.
 */
export async function connect(options: ConnectOptions): Promise<Client> {
  const stand = new URL(options.stand);
  const api = (path: string) => new URL(`/api/v1${path}`, stand);
  // An armed gate admits the reads before sign-in only with the pass.
  const gate: Record<string, string> = options.gatePass
    ? { cookie: `r8gate=${options.gatePass}` }
    : {};
  const privy = await privySignIn(
    options.secretKey,
    stand,
    await json<AgentClient>(
      await fetch(api("/auth/agent-client"), { headers: gate }),
      "agent client",
    ),
  );

  let session: Session | null = null;
  async function credentials(): Promise<Record<string, string>> {
    const access = await privy.getAccessToken();
    const identity = await privy.getIdentityToken();
    if (access === null || identity === null) {
      throw new Error("Privy holds no tokens: sign in again");
    }
    if (
      session === null || session.expiresAt - Date.now() < SESSION_MARGIN_MS
    ) {
      const issued = await json<{ token: string; expiresAt: string }>(
        await fetch(api("/auth/session"), {
          method: "POST",
          headers: {
            "content-type": "application/json",
            ...(session === null ? gate : {}),
          },
          body: JSON.stringify({
            privyAccessToken: access,
            privyIdentityToken: identity,
          }),
        }),
        "session exchange",
      );
      session = {
        token: issued.token,
        expiresAt: Date.parse(issued.expiresAt),
      };
    }
    return {
      authorization: `Bearer ${session.token}`,
      "x-privy-identity-token": identity,
    };
  }

  if (options.acceptTerms) {
    const headers = await credentials();
    const profile = await json<{ profileComplete: boolean }>(
      await fetch(api("/auth/session"), { headers }),
      "session",
    );
    if (!profile.profileComplete) {
      await json(
        await fetch(api("/profile"), {
          method: "PATCH",
          headers: { ...headers, "content-type": "application/json" },
          body: JSON.stringify({
            username: options.acceptTerms.username,
            acceptTerms: true,
          }),
        }),
        "profile",
      );
    }
  }

  const client = new Client({ name: "r8-agent", version: "1" });
  await client.connect(
    new StreamableHTTPClientTransport(new URL("/mcp", stand), {
      fetch: async (input, init) => {
        const headers = new Headers(init?.headers);
        for (const [name, value] of Object.entries(await credentials())) {
          headers.set(name, value);
        }
        return fetch(input, { ...init, headers });
      },
    }),
  );
  return client;
}
