// allows sub/unsub and publishing before connection is established.
// Much more refactoring is needed
// Don't rely on Relay interface, it will change (I'll probably delete a lot of code from here, there's no need for
// multiple listeners)

import {type Event, verifySignature, validateEvent} from "nostr-tools";
import {type Filter, matchFilters} from "nostr-tools";
import WebSocket from "isomorphic-ws";
import {getHex64, getSubName} from "./fakejson";

type RelayEvent = "connect" | "disconnect" | "error" | "notice" | "auth";

export type Relay = {
  url: string;
  status: number;
  connect: () => Promise<void>;
  close: () => Promise<void>;
  sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub;
  publish: (event: Event) => Pub;
  auth: (event: Event) => Pub;
  on: (type: RelayEvent, cb: any) => void;
  off: (type: RelayEvent, cb: any) => void;
};
export type Pub = {
  on: (type: "ok" | "seen" | "failed", cb: any) => void;
  off: (type: "ok" | "seen" | "failed", cb: any) => void;
};
export type Sub = {
  sub: (filters: Filter[], opts: SubscriptionOptions) => Sub;
  unsub: () => void;
  on: (type: "event" | "eose", cb: any) => void;
  off: (type: "event" | "eose", cb: any) => void;
};

type SubscriptionOptions = {
  skipVerification?: boolean;
  id?: string;
  eventIds?: Set<string>;
};
export function relayInit(
  url: string,
  alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined,
  autoReconnect?: boolean
): Relay {
  return new RelayC(url, alreadyHaveEvent, autoReconnect).relayInit();
}
class RelayC {
  url: string;
  alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined;
  logging: boolean = false;
  constructor(
    url: string,
    alreadyHaveEvent?: (id: string) => (Event & {id: string}) | undefined,
    autoReconnect?: boolean
  ) {
    this.url = url;
    this.alreadyHaveEvent = alreadyHaveEvent;
    this.autoReconnect = autoReconnect;
  }
  autoReconnect?: boolean;
  ws: WebSocket | undefined;
  sendOnConnect: string[] = [];
  openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {};
  closedByClient: boolean = false;
  listeners: {
    connect: Array<() => void>;
    disconnect: Array<() => void>;
    error: Array<() => void>;
    notice: Array<(msg: string) => void>;
    auth: Array<(challenge: string) => void>;
  } = {
    connect: [],
    disconnect: [],
    error: [],
    notice: [],
    auth: [],
  };
  subListeners: {
    [subid: string]:
      | {
          event: Array<(event: Event) => void>;
          eose: Array<() => void>;
        }
      | undefined;
  } = {};
  pubListeners: {
    [eventid: string]: {
      ok: Array<() => void>;
      seen: Array<() => void>;
      failed: Array<(reason: string) => void>;
    };
  } = {};
  incomingMessageQueue: string[] = [];
  handleNextInterval: any;

  #handleNext() {
    if (this.incomingMessageQueue.length === 0) {
      clearInterval(this.handleNextInterval);
      this.handleNextInterval = null;
      return;
    }
    this.#handleMessage({data: this.incomingMessageQueue.shift()});
  }

  async trySend(params: [string, ...any]) {
    const msg = JSON.stringify(params);

    if (this.connected) {
      this.ws?.send(msg);
    } else {
      this.sendOnConnect.push(msg);
    }
  }
  resolveClose: (() => void) | undefined = undefined;

  async #onclose() {
    if (this.closedByClient) {
      this.listeners.disconnect.forEach((cb) => cb());
      this.resolveClose && this.resolveClose();
    } else {
      if (this.autoReconnect) {
        this.#reconnect();
      }
    }
  }
  reconnectTimeout: number = 0;
  #reconnect() {
    setTimeout(() => {
      this.reconnectTimeout = Math.max(2000, this.reconnectTimeout * 3);
      console.log(
        this.url,
        "reconnecting after " + this.reconnectTimeout / 1000 + "s"
      );
      this.connect();
    }, this.reconnectTimeout);
  }

  async #onmessage(e: any) {
    this.incomingMessageQueue.push(e.data);
    if (!this.handleNextInterval) {
      this.handleNextInterval = setInterval(() => this.#handleNext(), 0);
    }
  }

  async #handleMessage(e: any) {
    let data;
    let json: string = e.data.toString();
    if (!json) {
      return;
    }
    let eventId = getHex64(json, "id");
    let event = this.alreadyHaveEvent?.(eventId);
    if (event) {
      const listener = this.subListeners[getSubName(json)];

      if (!listener) {
        return;
      }

      return listener.event.forEach((cb) => cb(event!));
    }
    try {
      data = JSON.parse(json);
    } catch (err) {
      data = e.data;
    }

    if (data.length >= 1) {
      switch (data[0]) {
        case "EVENT":
          if (this.logging) {
            console.log(data);
          }
          if (data.length !== 3) return; // ignore empty or malformed EVENT

          const id = data[1];
          const event = data[2];
          if (!this.openSubs[id]) {
            return;
          }
          if (this.openSubs[id].eventIds?.has(eventId)) {
            return;
          }
          this.openSubs[id].eventIds?.add(eventId);

          if (
            validateEvent(event) &&
            this.openSubs[id] &&
            (this.openSubs[id].skipVerification || verifySignature(event)) &&
            matchFilters(this.openSubs[id].filters, event)
          ) {
            this.openSubs[id];
            (this.subListeners[id]?.event || []).forEach((cb) => cb(event));
          }
          return;
        case "EOSE": {
          if (data.length !== 2) return; // ignore empty or malformed EOSE
          const id = data[1];
          if (this.logging) {
            console.log("EOSE", this.url, id);
          }
          (this.subListeners[id]?.eose || []).forEach((cb) => cb());
          return;
        }
        case "OK": {
          if (data.length < 3) return; // ignore empty or malformed OK
          const id: string = data[1];
          const ok: boolean = data[2];
          const reason: string = data[3] || "";
          if (ok) this.pubListeners[id]?.ok.forEach((cb) => cb());
          else this.pubListeners[id]?.failed.forEach((cb) => cb(reason));
          return;
        }
        case "NOTICE":
          if (data.length !== 2) return; // ignore empty or malformed NOTICE
          const notice = data[1];
          this.listeners.notice.forEach((cb) => cb(notice));
          return;
        case "AUTH":
          if (data.length !== 2) return;
          const challenge = data[1];
          this.listeners.auth.forEach((cb) => cb(challenge));
          return;
      }
    }
  }
  #onopen(opened: () => void) {
    if (this.resolveClose) {
      this.resolveClose();
      return;
    }
    // console.log("#onopen setting reconnectTimeout to 0");
    // this.reconnectTimeout = 0;
    // TODO: Send ephereal messages after subscription, permament before
    for (const subid in this.openSubs) {
      if (this.logging) {
        console.log("REQ", this.url, subid, ...this.openSubs[subid].filters);
      }
      this.trySend(["REQ", subid, ...this.openSubs[subid].filters]);
    }
    for (const msg of this.sendOnConnect) {
      if (this.logging) {
        console.log("(Relay msg)", this.url, msg);
      }
      this.ws?.send(msg);
    }
    this.sendOnConnect = [];

    this.listeners.connect.forEach((cb) => cb());
    opened();
  }

  async connectRelay(): Promise<void> {
    return new Promise((resolve, reject) => {
      try {
        const ws = new WebSocket(this.url);
        this.ws = ws;
      } catch (err) {
        reject(err);
        return;
      }

      this.ws.onopen = this.#onopen.bind(this, resolve);
      this.ws.onerror = (e) => {
        this.listeners.error.forEach((cb) => cb());
        reject(e);
      };
      this.ws.onclose = this.#onclose.bind(this);
      this.ws.onmessage = this.#onmessage.bind(this);
    });
  }

  async connect(): Promise<void> {
    if (this.ws?.readyState && this.ws.readyState < 2) return; // ws already open or connecting
    if (this.ws?.readyState === 2) {
      this.ws.close();
    }
    this.ws = undefined;
    await this.connectRelay();
  }

  relayInit(): Relay {
    const this2 = this;
    return {
      url: this2.url,
      sub: this2.sub.bind(this2),
      on: this2.on.bind(this2),
      off: this2.off.bind(this2),
      auth: this2.auth.bind(this2),
      publish: this2.publish.bind(this2),
      connect: this2.connect.bind(this2),
      close(): Promise<void> {
        return this2.close();
      },
      get status() {
        return this2.status;
      },
      // @ts-ignore
      relay: this2,
    };
  }
  get status() {
    return this.ws?.readyState ?? 3;
  }
  get connected() {
    return this.ws?.readyState === 1;
  }
  close(): Promise<void> {
    this.closedByClient = true;
    this.ws?.close();
    return new Promise<void>((resolve) => {
      this.resolveClose = resolve;
    });
  }
  on(type: RelayEvent, cb: any) {
    this.listeners[type].push(cb);
    if (type === "connect" && this.ws?.readyState === 1) {
      cb();
    }
  }

  off(type: RelayEvent, cb: any) {
    const index = this.listeners[type].indexOf(cb);
    if (index !== -1) this.listeners[type].splice(index, 1);
  }

  publish(event: Event): Pub {
    return this._publish(event, "EVENT");
  }

  auth(event: Event): Pub {
    return this._publish(event, "AUTH");
  }

  private _publish(event: Event, type: string) {
    const this2 = this;
    if (!event.id) throw new Error(`event ${event} has no id`);
    const id = event.id;

    let sent = false;
    let mustMonitor = false;

    this2
      .trySend([type, event])
      .then(() => {
        sent = true;
        if (mustMonitor) {
          startMonitoring();
          mustMonitor = false;
        }
      })
      .catch(() => {});

    const startMonitoring = () => {
      const monitor = this.sub([{ids: [id]}], {
        id: `monitor-${id.slice(0, 5)}`,
      });
      const willUnsub = setTimeout(() => {
        (this2.pubListeners[id]?.failed || []).forEach((cb) =>
          cb("event not seen after 5 seconds")
        );
        monitor.unsub();
      }, 5000);
      monitor.on("event", () => {
        clearTimeout(willUnsub);
        (this2.pubListeners[id]?.seen || []).forEach((cb) => cb());
      });
    };

    return {
      on: (type: "ok" | "seen" | "failed", cb: any) => {
        this2.pubListeners[id] = this2.pubListeners[id] || {
          ok: [],
          seen: [],
          failed: [],
        };
        this2.pubListeners[id][type].push(cb);

        if (type === "seen") {
          if (sent) startMonitoring();
          else mustMonitor = true;
        }
      },
      off: (type: "ok" | "seen" | "failed", cb: any) => {
        const listeners = this2.pubListeners[id];
        if (!listeners) return;
        const idx = listeners[type].indexOf(cb);
        if (idx >= 0) listeners[type].splice(idx, 1);
      },
    };
  }

  sub(filters: Filter[], opts: SubscriptionOptions = {}): Sub {
    const this2 = this;
    const subid = opts.id || Math.random().toString().slice(2);
    const skipVerification = opts.skipVerification || false;

    this2.openSubs[subid] = {
      id: subid,
      filters,
      skipVerification,
    };
    if (this2.connected) {
      if (this.logging) {
        console.log("REQ2", this.url, subid, ...filters);
      }
      this2.trySend(["REQ", subid, ...filters]);
    }

    return {
      sub: (newFilters, newOpts = {}) =>
        this.sub(newFilters || filters, {
          skipVerification: newOpts.skipVerification || skipVerification,
          id: subid,
        }),
      unsub: () => {
        delete this2.openSubs[subid];
        delete this2.subListeners[subid];
        if (this2.connected) {
          if (this2.logging) {
            console.log("CLOSE", this.url, subid);
          }
          this2.trySend(["CLOSE", subid]);
        }
      },
      on: (type: "event" | "eose", cb: any): void => {
        this2.subListeners[subid] = this2.subListeners[subid] || {
          event: [],
          eose: [],
        };
        this2.subListeners[subid]![type].push(cb);
      },
      off: (type: "event" | "eose", cb: any): void => {
        const listeners = this2.subListeners[subid];

        if (!listeners) return;

        const idx = listeners[type].indexOf(cb);
        if (idx >= 0) listeners[type].splice(idx, 1);
      },
    };
  }
}
