import { parse, render } from '@urbit/aura';
import { Atom, Cell, Noun, dejs, enjs, jam } from '@urbit/nockjs';
import { isBrowser } from 'browser-or-node';

import { TimeoutError } from '../api';
import { createDevLogger } from '../debug';
import { desig } from '../urbit';
import { readArrayBufferFromBlob } from '../utils';
import * as utils from '../utils';
import { EventEmitter } from '../utils/EventEmitter';
import { UrbitHttpApiEvent, UrbitHttpApiEventType } from './events';
import {
  EventSourceMessage,
  FetchEventSourceInit,
  fetchEventSource,
} from './fetch-event-source';
import {
  Ack,
  AuthError,
  AuthenticationInterface,
  FatalError,
  Message,
  NounPoke,
  NounPokeInterface,
  PokeHandlers,
  PokeInterface,
  ReapError,
  SSEBadResponseError,
  SSEOptions,
  SSETimeoutError,
  Scry,
  SubscriptionRequestInterface,
  Thread,
  headers,
} from './types';
import { hexString, unpackJamBytes } from './utils';

const logger = createDevLogger('UrbitHttpApi', false);

//TODO  move into nockjs utils
function isNoun(a: any): a is Noun {
  return a instanceof Atom || a instanceof Cell;
}

type UrbitHttpApiEventMap = {
  [E in keyof UrbitHttpApiEvent]: (event: UrbitHttpApiEvent[E]) => void;
};

/**
 * A class for interacting with an urbit ship, given its URL and code
 */
export class Urbit {
  /**
   * Event emitter for debugging, see events.ts for full list of events
   */
  private emitter = new EventEmitter<UrbitHttpApiEventMap>();

  /**
   * UID will be used for the channel: The current unix time plus a random hex string
   */
  private uid: string = `${Math.floor(Date.now() / 1000)}-${hexString(6)}`;

  /**
   * lastEventId is an auto-updated index of which events have been *sent* over this channel.
   * lastHeardEventId is the latest event we have heard back about.
   * lastAcknowledgedEventId is the latest event we have sent an ack for.
   */
  private lastEventId: number = 0;
  private lastHeardEventId: number = -1;
  private lastAcknowledgedEventId: number = -1;

  /**
   * SSE Client is null for now; we don't want to start polling until it the channel exists
   */
  private sseClientInitialized: boolean = false;

  /**
   * Cookie gets set when we log in.
   */
  cookie?: string;

  /**
   * A registry of requestId to successFunc/failureFunc
   *
   * These functions are registered during a +poke and are executed
   * in the onServerEvent()/onServerError() callbacks. Only one of
   * the functions will be called, and the outstanding poke will be
   * removed after calling the success or failure function.
   */

  private outstandingPokes: Map<number, PokeHandlers> = new Map();

  /**
   * A registry of requestId to subscription functions.
   *
   * These functions are registered during a +subscribe and are
   * executed in the onServerEvent()/onServerError() callbacks. The
   * event function will be called whenever a new piece of data on this
   * subscription is available, which may be 0, 1, or many times. The
   * disconnect function may be called exactly once.
   */
  private outstandingSubscriptions: Map<number, SubscriptionRequestInterface> =
    new Map();

  /**
   * Our abort controller, used to close the connection
   */
  private channelAbort = new AbortController();

  /**
   * Identity of the ship we're connected to
   */
  nodeId?: string | null;

  /**
   * Our identity, with which we are authenticated into the ship
   */
  our?: string | null;

  /**
   * If verbose, logs output eagerly.
   */
  verbose?: boolean;

  /**
   * number of consecutive errors in connecting to the eventsource
   */
  private errorCount = 0;

  /**
   * Custom fetch implementation to use.
   */
  fetchFn: typeof fetch = (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init);

  /** This is basic interpolation to get the channel URL of an instantiated Urbit connection. */
  private get channelUrl(): string {
    return `${this.url}/~/channel/${this.uid}`;
  }

  private get fetchOptions(): any {
    const headers: headers = {
      'Content-Type': 'application/json',
    };

    // In Node.js, manually set cookie header
    if (!isBrowser && this.cookie) {
      headers['Cookie'] = this.cookie;
    }

    return {
      credentials: isBrowser ? 'include' : undefined,
      accept: '*',
      headers,
    };
  }

  private fetchOptionsNoun(
    method: 'PUT' | 'GET' = 'PUT',
    mode: 'noun' | 'json' = 'noun'
  ): any {
    let type;
    switch (mode) {
      case 'noun':
        type = 'application/x-urb-jam';
        break;
      case 'json':
        type = 'application/json';
        break;
    }
    const headers: Record<string, string | undefined> = {};
    switch (method) {
      case 'PUT':
        headers['Content-Type'] = type;
        headers['Accept'] = type;
        break;
      case 'GET':
        headers['X-Channel-Format'] = type;
        break;
    }
    if (!isBrowser) {
      headers.Cookie = this.cookie;
    }
    return {
      credentials: 'include',
      accept: '*',
      headers,
    };
  }

  /**
   * Constructs a new Urbit connection.
   *
   * @param url  The URL (with protocol and port) of the ship to be accessed. If
   * the airlock is running in a webpage served by the ship, this should just
   * be the empty string.
   * @param code The access code for the ship at that address
   */
  constructor(
    public url: string,
    public code?: string,
    public desk?: string,
    fetchFn?: typeof fetch
  ) {
    if (isBrowser) {
      window.addEventListener('beforeunload', this.delete);
    }
    if (fetchFn) {
      this.fetchFn = fetchFn;
    }
    return this;
  }

  /**
   * All-in-one hook-me-up.
   *
   * Given a ship, url, and code, this returns an airlock connection
   * that is ready to go. It `|hi`s itself to create the channel,
   * then opens the channel via EventSource.
   *
   */
  //TODO  rename this to connect() and only do constructor & event source setup.
  //      that way it can be used with the assumption that you're already
  //      authenticated.
  static async authenticate({
    ship,
    url,
    code,
    verbose = false,
  }: AuthenticationInterface) {
    const airlock = new Urbit(
      url.startsWith('http') ? url : `http://${url}`,
      code
    );
    airlock.verbose = verbose;
    airlock.nodeId = ship;
    await airlock.connect();
    await airlock.poke({
      app: 'hood',
      mark: 'helm-hi',
      json: 'opening airlock',
    });
    await airlock.eventSource();
    return airlock;
  }

  private emit<T extends UrbitHttpApiEventType>(
    event: T,
    ...data: Parameters<UrbitHttpApiEventMap[T]>
  ) {
    this.emitter.emit(event, ...data);
  }

  on<T extends keyof UrbitHttpApiEventMap>(
    event: T,
    callback: UrbitHttpApiEventMap[T]
  ): void {
    this.emitter.on(event, callback);

    this.verbose && console.log(event, 'listening active');
    if (event === 'init') {
      this.emitter.emit('init', {
        uid: this.uid,
        subscriptions: [...this.outstandingSubscriptions.entries()].map(
          ([k, v]) => ({ id: k, app: v.app, path: v.path })
        ),
      });
    }
  }

  /**
   * Gets the name of the ship accessible at this.url and stores it to this.ship
   *
   */
  async getShipName(): Promise<void> {
    if (this.nodeId) {
      return Promise.resolve();
    }

    const nameResp = await this.fetchFn(`${this.url}/~/host`, {
      method: 'get',
      credentials: 'include',
    });
    const name = await nameResp.text();
    this.nodeId = name;
  }

  /**
   * Gets the name of the ship accessible at this.url and stores it to this.ship
   *
   */
  async getOurName(): Promise<void> {
    const headers: Record<string, string> = {};
    if (!isBrowser && this.cookie) {
      headers['Cookie'] = this.cookie;
    }
    const nameResp = await this.fetchFn(`${this.url}/~/name`, {
      method: 'get',
      credentials: 'include',
      headers,
    });
    const name = await nameResp.text();
    this.our = name;
  }

  /**
   * Connects to the Urbit ship. Nothing can be done until this is called.
   * That's why we roll it into this.authenticate
   * TODO  as of urbit/urbit#6561, this is no longer true, and we are able
   *       to interact with the ship using a guest identity.
   */
  //TODO  rename to authenticate() and call connect() at the end
  async connect(): Promise<void> {
    if (this.verbose) {
      console.log(
        `password=${this.code} `,
        isBrowser
          ? 'Connecting in browser context at ' + `${this.url}/~/login`
          : 'Connecting from node context'
      );
    }
    return this.fetchFn(`${this.url}/~/login`, {
      method: 'post',
      body: `password=${this.code}`,
      credentials: 'include',
    }).then(async (response: Response) => {
      if (this.verbose) {
        console.log('Received authentication response', response);
      }
      if (response.status < 200 || response.status >= 300) {
        throw new Error('Login failed with status ' + response.status);
      }
      const cookie = response.headers.get('set-cookie');
      if (!this.nodeId && cookie) {
        const match = new RegExp(/urbauth-(~[\w-]+)/).exec(cookie)?.[1];
        this.nodeId = match;
      }
      if (!isBrowser) {
        // Parse just the cookie key=value from Set-Cookie header.
        // The header includes attributes like "Path=/; Max-Age=..." but
        // we only want to send the actual cookie value in subsequent requests.
        this.cookie = cookie?.split(';')[0].trim() || undefined;
      }
      await this.getShipName();
      await this.getOurName();
    });
  }

  /**
   * Initializes the SSE pipe for the appropriate channel.
   */
  async eventSource(): Promise<void> {
    if (this.sseClientInitialized) {
      return Promise.resolve();
    }
    if (this.lastEventId === 0) {
      this.emit('status-update', { status: 'opening' });
      // Can't receive events until the channel is open,
      // so poke and open then
      await this.poke({
        app: 'hood',
        mark: 'helm-hi',
        json: 'Opening API channel',
      });
      return;
    }
    this.sseClientInitialized = true;
    return new Promise((resolve, reject) => {
      const sseOptions: SSEOptions = {
        headers: {},
      };
      if (isBrowser) {
        sseOptions.withCredentials = true;
      }
      fetchEventSource(this.channelUrl, {
        ...this.fetchOptions,
        signal: this.channelAbort.signal,
        reactNative: { textStreaming: true },
        openWhenHidden: true,
        responseTimeout: 25000,
        fetch: this.fetchFn,
        onopen: async (response, isReconnect) => {
          if (this.verbose) {
            console.log('Opened eventsource', response);
          }
          if (response.ok) {
            this.errorCount = 0;
            this.emit('status-update', {
              status: isReconnect ? 'reconnected' : 'active',
            });
            resolve();
            return; // everything's good
          } else {
            const err = new Error('failed to open eventsource');
            reject(err);
          }
        },
        onmessage: (event: EventSourceMessage) => {
          if (this.verbose) {
            console.log('Received SSE: ', event);
          }
          if (!event.id) return;
          const eventId = parseInt(event.id, 10);
          this.emit('fact', {
            id: eventId,
            data: event.data,
            time: Date.now(),
          });
          if (eventId <= this.lastHeardEventId) {
            if (this.verbose) {
              console.log('dropping old or out-of-order event', {
                eventId,
                lastHeard: this.lastHeardEventId,
              });
            }
            return;
          }
          this.lastHeardEventId = eventId;
          this.emit('id-update', { lastHeard: this.lastHeardEventId });
          if (eventId - this.lastAcknowledgedEventId > 20) {
            this.ack(eventId);
          }

          if (event.data && JSON.parse(event.data)) {
            const data: any = JSON.parse(event.data);

            if (this.verbose) {
              console.log(`received data`, data);
            }

            if (
              data.response === 'poke' &&
              this.outstandingPokes.has(data.id)
            ) {
              const funcs = this.outstandingPokes.get(data.id);
              if ('ok' in data && funcs) {
                funcs.onSuccess?.();
              } else if ('err' in data && funcs) {
                console.error(data.err);
                funcs.onError?.(data.err);
              } else {
                console.error('Invalid poke response', data);
              }
              this.outstandingPokes.delete(data.id);
            } else if (
              data.response === 'subscribe' &&
              this.outstandingSubscriptions.has(data.id)
            ) {
              const funcs = this.outstandingSubscriptions.get(data.id);
              if ('err' in data && funcs) {
                console.error(data.err);
                funcs.err?.(data.err, data.id);
                this.outstandingSubscriptions.delete(data.id);
              }
            } else if (
              data.response === 'diff' &&
              this.outstandingSubscriptions.has(data.id)
            ) {
              const funcs = this.outstandingSubscriptions.get(data.id);
              try {
                funcs?.event?.(data.json, data.mark ?? 'json', data.id);
              } catch (e) {
                console.error('Failed to call subscription event callback', e);
              }
            } else if (
              data.response === 'quit' &&
              this.outstandingSubscriptions.has(data.id)
            ) {
              const sub = this.outstandingSubscriptions.get(data.id);
              sub?.quit?.(data);
              this.outstandingSubscriptions.delete(data.id);
              this.emit('subscription', {
                id: data.id,
                status: 'close',
              });
              if (sub?.resubOnQuit) {
                this.subscribe(sub);
              }
            } else if (this.verbose) {
              console.log([...this.outstandingSubscriptions.keys()]);
              console.log('Unrecognized response', data);
            }
          }
        },
        onerror: (error) => {
          this.errorCount++;
          this.emit('error', {
            time: Date.now(),
            msg: JSON.stringify(error),
            error,
          });
          if (error instanceof ReapError) {
            this.emit('channel-reaped', { time: Date.now() });
            this.seamlessReset();
            return;
          }
          if (!(error instanceof FatalError)) {
            const context: any = {};
            if (error instanceof SSEBadResponseError) {
              if (error.status === 500) {
                this.seamlessReset();
                return;
              }
              context.message = error.message;
              context.requestStatus = error.status;
            }
            context.message = error.message;
            this.emit('status-update', { status: 'reconnecting', context });
            return Math.min(5000, Math.pow(2, this.errorCount - 1) * 750);
          }
          this.emit('status-update', { status: 'errored' });
          throw error;
        },
        onclose: () => {
          console.log('e');
          throw new Error('Ship unexpectedly closed the connection');
        },
      });
    });
  }

  /**
   * Reset airlock, abandoning current subscriptions and wiping state
   *
   */
  reset() {
    if (this.verbose) {
      console.log('resetting');
    }
    this.delete();
    this.uid = `${Math.floor(Date.now() / 1000)}-${hexString(6)}`;
    this.emit('reset', { uid: this.uid });
    this.lastEventId = 0;
    this.lastHeardEventId = -1;
    this.lastAcknowledgedEventId = -1;
    this.outstandingSubscriptions = new Map();
    this.outstandingPokes = new Map();
    this.sseClientInitialized = false;
  }

  seamlessReset() {
    // called if a channel was reaped by %eyre before we reconnected
    // so we have to make a new channel.
    this.uid = `${Math.floor(Date.now() / 1000)}-${hexString(6)}`;
    this.emit('seamless-reset', { uid: this.uid });
    this.emit('status-update', { status: 'initial' });
    this.sseClientInitialized = false;
    this.lastEventId = 0;
    this.lastHeardEventId = -1;
    this.lastAcknowledgedEventId = -1;
    const oldSubs = [...this.outstandingSubscriptions.entries()];
    this.outstandingSubscriptions = new Map();
    oldSubs.forEach(([id, sub]) => {
      sub.quit?.({
        id,
        response: 'quit',
      });
      this.emit('subscription', {
        id,
        status: 'close',
      });

      if (sub.resubOnQuit) {
        this.subscribe(sub);
      }
    });

    this.outstandingPokes.forEach((poke, id) => {
      poke.onError?.('Channel was reaped');
    });
    this.outstandingPokes = new Map();
  }

  /**
   * Autoincrements the next event ID for the appropriate channel.
   */
  private getEventId(): number {
    this.lastEventId += 1;
    this.emit('id-update', { current: this.lastEventId });
    return this.lastEventId;
  }

  /**
   * Acknowledges an event.
   *
   * @param eventId The event to acknowledge.
   */
  private async ack(eventId: number): Promise<number | void> {
    this.lastAcknowledgedEventId = eventId;
    this.emit('id-update', { lastAcknowledged: eventId });
    const message: Ack = {
      action: 'ack',
      'event-id': eventId,
    };
    await this.sendJSONtoChannel(message);
    return eventId;
  }

  //NOTE  every arg is interpreted (through nockjs.dwim) as a noun, which
  //      should result in a noun nesting inside of the xx $eyre-command type
  private async sendNounsToChannel(...args: (Noun | any)[]): Promise<void> {
    const options = this.fetchOptionsNoun('PUT', 'noun');
    const body = render('uw', jam(dejs.list(args)).number);
    this.validatePokeBodySize(body);

    const response = await this.fetchFn(this.channelUrl, {
      ...options,
      signal: this.channelAbort.signal,
      method: 'PUT',
      body,
    });
    if (!response.ok) {
      console.log(response.status, response.statusText, await response.text());
      throw new Error('Failed to PUT channel command(s)');
    }
    if (!this.sseClientInitialized) {
      if (this.verbose) {
        console.log('initializing event source');
      }
      await Promise.all([this.getOurName(), this.getShipName()]);

      if (this.our !== this.nodeId) {
        throw new AuthError('invalid session');
      }

      await this.eventSource();
    }
  }

  private async sendJSONtoChannel(...json: (Message | Ack)[]): Promise<void> {
    const body = JSON.stringify(json);
    this.validatePokeBodySize(body);

    const response = await this.fetchFn(this.channelUrl, {
      ...this.fetchOptions,
      signal: this.channelAbort.signal,
      method: 'PUT',
      body,
    });

    if (!response.ok) {
      throw new Error('Failed to PUT channel');
    }
    if (!this.sseClientInitialized) {
      if (this.verbose) {
        console.log('initializing event source');
      }
      await Promise.all([this.getOurName(), this.getShipName()]);

      if (this.our !== this.nodeId) {
        console.log('our name does not match ship name');
        console.log('our:', this.our);
        console.log('ship:', this.nodeId);
        console.log('messages:', json);
        throw new AuthError('invalid session');
      }

      await this.eventSource();
    }
  }

  /**
   * Validates the size of the poke body.
   * This prevents us from accidentally sending large payloads (eg base64 images)
   * @param body The body to validate.
   */
  validatePokeBodySize(body: string) {
    if (body.length / 1024 > 512) {
      logger.trackError('Body too large to send to channel');
      throw new Error('Body too large to send to channel');
    }
  }

  /**
   * Creates a subscription, waits for a fact and then unsubscribes
   *
   * @param app Name of gall agent to subscribe to
   * @param path Path to subscribe to
   * @param timeout Optional timeout before ending subscription
   *
   * @returns The first fact on the subcription
   */
  async subscribeOnce<T = any>(
    app: string,
    path: string,
    ship?: string,
    timeout?: number
  ) {
    return new Promise<T>((resolve, reject) => {
      let done = false;
      const quit = () => {
        if (!done) {
          reject('quit');
        }
      };
      const event = (e: T, mark: string, id: number) => {
        if (!done) {
          resolve(e);
          this.unsubscribe(id);
        }
      };
      const request = {
        app,
        path,
        ship,
        resubOnQuit: false,
        event,
        err: reject,
        quit,
      };

      this.subscribe(request).then((subId) => {
        if (timeout) {
          setTimeout(() => {
            if (!done) {
              done = true;
              reject('timeout');
              this.unsubscribe(subId);
            }
          }, timeout);
        }
      });
    });
  }

  async pokeNoun(params: NounPokeInterface): Promise<number> {
    params.onSuccess = params.onSuccess || (() => {});
    params.onError = params.onError || (() => {});
    const { app, mark, noun, ship } = {
      ship: this.nodeId?.replace('~', '') || '',
      ...params,
    };

    if (this.lastEventId === 0) {
      this.emit('status-update', { status: 'opening' });
    }

    const eventId = this.getEventId();
    this.outstandingPokes.set(eventId, params);

    if (isNoun(noun)) {
      const shipAtom = new Atom(parse('p', `~${ship}`));
      const non = ['poke', eventId, shipAtom, app, mark, noun];
      await this.sendNounsToChannel(non);
    } else {
      throw new Error('pokeNoun requires a noun');
    }
    return eventId;
  }

  /**
   * Pokes a ship with data.
   *
   * @param app The app to poke
   * @param mark The mark of the data being sent
   * @param json The data to send
   */
  async poke<T>(params: PokeInterface<T>): Promise<number> {
    const { app, mark, json, ship, onSuccess, onError } = {
      onSuccess: () => {},
      onError: () => {},
      ship: desig(this.nodeId ?? ''),
      ...params,
    };

    if (this.lastEventId === 0) {
      this.emit('status-update', { status: 'opening' });
    }

    const message: Message = {
      id: this.getEventId(),
      action: 'poke',
      ship,
      app,
      mark,
      json,
    };

    return new Promise((resolve, reject) => {
      this.outstandingPokes.set(message.id, {
        onSuccess: () => {
          onSuccess();
          resolve(message.id);
        },
        onError: (err) => {
          onError(err);
          reject(err);
        },
      });
      this.sendJSONtoChannel(message).catch(reject);
    });
  }

  /**
   * Subscribes to a path on an app on a ship.
   *
   *
   * @param app The app to subsribe to
   * @param path The path to which to subscribe
   * @param handlers Handlers to deal with various events of the subscription
   */
  async subscribe(params: SubscriptionRequestInterface): Promise<number> {
    const { app, path, ship, resubOnQuit, err, event, quit } = {
      err: () => {},
      event: () => {},
      quit: () => {},
      resubOnQuit: true,
      ...params,
      ship: desig(params.ship ?? this.nodeId ?? ''),
    };

    if (this.lastEventId === 0) {
      this.emit('status-update', { status: 'opening' });
    }

    const message: Message = {
      id: this.getEventId(),
      action: 'subscribe',
      ship,
      app,
      path,
    };

    this.outstandingSubscriptions.set(message.id, {
      app,
      path,
      resubOnQuit,
      err,
      event,
      quit,
    });

    this.emit('subscription', {
      id: message.id,
      app,
      path,
      status: 'open',
    });

    await this.sendJSONtoChannel(message);

    return message.id;
  }

  /**
   * Unsubscribes to a given subscription.
   *
   * @param subscription
   */
  async unsubscribe(subscription: number) {
    return this.sendJSONtoChannel({
      id: this.getEventId(),
      action: 'unsubscribe',
      subscription,
    }).then(() => {
      this.emit('subscription', {
        id: subscription,
        status: 'close',
      });
      this.outstandingSubscriptions.delete(subscription);
    });
  }

  /**
   * Deletes the connection to a channel.
   */
  async delete() {
    this.channelAbort.abort();
    this.channelAbort = new AbortController();
    const body = JSON.stringify([
      {
        id: this.getEventId(),
        action: 'delete',
      },
    ]);
    if (isBrowser) {
      navigator.sendBeacon(this.channelUrl, body);
    } else {
      const response = await this.fetchFn(this.channelUrl, {
        ...this.fetchOptions,
        signal: this.channelAbort.signal,
        method: 'POST',
        body: body,
      });
      if (!response.ok) {
        throw new Error('Failed to DELETE channel in node context');
      }
    }
  }

  async checkIsNodeBusy(): Promise<'available' | 'busy' | 'unknown'> {
    try {
      const response = await this.fetchFn(`${this.url}/~_~/healthz`, {
        method: 'GET',
      });
      if (response.status === 204) {
        return 'available';
      }
      if (response.status === 429) {
        return 'busy';
      }
      logger.trackEvent('Unexpected node busy response', {
        status: response.status,
      });
      return 'unknown';
    } catch (e) {
      logger.trackEvent('Failed to check if node is busy', { error: e });
      return 'unknown';
    }
  }

  /**
   * Scry into an gall agent at a path
   *
   * @typeParam T - Type of the scry result
   *
   * @remarks
   *
   * Equivalent to
   * ```hoon
   * .^(T %gx /(scot %p our)/[app]/(scot %da now)/[path]/json)
   * ```
   * The returned cage must have a conversion to JSON for the scry to succeed
   *
   * @param params The scry request
   * @returns The scry result
   */
  async scry<T = any>(params: Scry): Promise<T> {
    const { result } = await this.scryWithInfo(params);
    return result;
  }

  async scryWithInfo<T = any>(
    params: Scry
  ): Promise<{
    responseStatus: number;
    responseSizeInBytes: number;
    result: T;
  }> {
    const { app, path, timeout } = params;
    const signal = timeout ? utils.createTimeoutSignal(timeout) : undefined;
    const response = await this.fetchFn(
      `${this.url}/~/scry/${app}${path}.json`,
      {
        ...this.fetchOptions,
        signal,
      }
    );
    signal?.cleanup();

    if (!response.ok) {
      return Promise.reject(response);
    }

    const result = await response.json();
    const responseSize = response.headers.get('content-length');
    return {
      responseStatus: response.status,
      responseSizeInBytes: Number(responseSize),
      result,
    };
  }

  async scryNoun(params: Scry): Promise<Noun> {
    const { result } = await this.scryNounWithInfo(params);
    return result;
  }

  async scryNounWithInfo(params: Scry): Promise<{
    responseStatus: number;
    responseSizeInBytes: number;
    result: Noun;
  }> {
    const { app, path } = params;

    try {
      const response = await this.fetchFn(
        `${this.url}/~/scry/${app}${path}.noun`,
        {
          ...this.fetchOptionsNoun('GET', 'noun'),
        }
      );

      if (!response.ok) {
        return Promise.reject(response);
      }

      const responseBlob = await response.blob();
      const buffer = await readArrayBufferFromBlob(responseBlob);

      try {
        const unpacked = await unpackJamBytes(buffer);
        const responseSize = response.headers.get('content-length');
        return {
          responseStatus: response.status,
          responseSizeInBytes: Number(responseSize),
          result: unpacked,
        };
      } catch (e) {
        console.error('Unpack failed', e);
        throw e;
      }
    } catch (e) {
      console.error(e);
      throw e;
    }
  }

  /**
   * Run a thread
   *
   *
   * @param inputMark   The mark of the data being sent
   * @param outputMark  The mark of the data being returned
   * @param threadName  The thread to run
   * @param body        The data to send to the thread
   * @returns  The return value of the thread
   */
  async thread<T = any>(params: Thread<T>): Promise<Response> {
    const {
      inputMark,
      outputMark,
      threadName,
      body,
      timeout,
      desk = this.desk,
    } = params;
    if (!desk) {
      throw new Error('Must supply desk to run thread from');
    }

    const signal = timeout ? utils.createTimeoutSignal(timeout) : undefined;

    const result = await this.fetchFn(
      `${this.url}/spider/${desk}/${inputMark}/${threadName}/${outputMark}.json`,
      {
        ...this.fetchOptions,
        signal,
        method: 'POST',
        body: JSON.stringify(body),
      }
    );
    signal?.cleanup();
    return result;
  }

  async getSpinHints(): Promise<string> {
    return new Promise<string>((resolve, reject) => {
      const controller = new AbortController();
      let messageReceived = false;

      fetchEventSource(`${this.url}/~_~/spin`, {
        signal: controller.signal,
        // @ts-expect-error reactNative not in types but is essential
        reactNative: { textStreaming: true },
        openWhenHidden: true,
        responseTimeout: 25000,
        fetch: this.fetchFn,
        onmessage(event) {
          if (!messageReceived) {
            messageReceived = true;
            controller.abort();
            resolve(event.data);
          }
        },
        onerror(error) {
          controller.abort();
          reject(error);
        },
      });
    });
  }

  /**
   * Perform a standard HTTP request using the channel's authentication
   *
   * @param path The path to request (relative to the ship's URL)
   * @param options Request options (method, headers, body, etc.)
   * @returns The response from the request
   */
  async request<T>(
    path: string,
    options: RequestInit = {},
    timeout?: number
  ): Promise<T> {
    // Ensure path starts with a slash if not provided
    if (!path.startsWith('/')) {
      path = '/' + path;
    }

    const signal = timeout ? utils.createTimeoutSignal(timeout) : undefined;
    // Prepare request options with authentication
    const requestOptions: RequestInit = {
      ...this.fetchOptions,
      ...options,
      // Merge headers properly
      headers: {
        ...this.fetchOptions.headers,
        ...(options.headers || {}),
      },
      signal,
    };

    // If we're in a Node.js environment, add the cookie for authentication
    if (!isBrowser && this.cookie) {
      requestOptions.headers = {
        ...requestOptions.headers,
        Cookie: this.cookie,
      };
    }

    // Make the request
    const response = await this.fetchFn(`${this.url}${path}`, requestOptions);
    signal?.cleanup();

    // Handle response
    if (!response.ok) {
      return Promise.reject(response);
    }

    // Determine response type and parse accordingly
    const contentType = response.headers.get('content-type');
    if (contentType?.includes('application/json')) {
      return response.json();
    } else if (contentType?.includes('text/')) {
      return response.text() as unknown as T;
    } else {
      return response.blob() as unknown as T;
    }
  }

  /**
   * Utility function to connect to a ship that has its *.arvo.network domain configured.
   *
   * @param name Name of the ship e.g. zod
   * @param code Code to log in
   */
  static async onArvoNetwork(ship: string, code: string): Promise<Urbit> {
    const url = `https://${ship}.arvo.network`;
    return await Urbit.authenticate({ ship, url, code });
  }
}

export default Urbit;
