import type {Filter, Event} from "nostr-tools";
import {mergeSimilarAndRemoveEmptyFilters} from "./merge-similar-filters";
import {type Relay, relayInit, type Sub} from "./relay";
import type {OnEventObject, OnEvent} from "./on-event-filters";
import {EventCache} from "./event-cache";
import {EventObject} from "./event";
import {
  batchFiltersByRelay,
  groupFiltersByRelayAndEmitCacheHits,
} from "./group-filters-by-relay";
import type {CallbackReplayer} from "./callback-replayer";
import {NewestEventCache} from "./newest-event-cache";

const unique = (arr: string[]) => [...new Set(arr)];

export {type OnEvent, type OnEventObject} from "./on-event-filters";
export type OnEose = (relayUrl: string, minCreatedAt: number) => void;
export type OnEventAndMetadata = (event: Event, metadata: Event) => void;
export type FilterToSubscribe = [
  onEvent: OnEvent,
  filtersByRelay: Map<string, Filter[]>,
  unsub: {unsubcb?: () => void},
  unsubscribeOnEose?: boolean,
  subscriptionCacheKey?: string,
  maxDelayms?: number
];

export type SubscriptionOptions = {
  allowDuplicateEvents?: boolean;
  allowOlderEvents?: boolean;
  logAllEvents?: boolean;
  unsubscribeOnEose?: boolean;
  defaultRelays?: string[];
  dontSendOtherFilters?: boolean;
};

function parseJSON(json: string | undefined) {
  if (json) {
    return JSON.parse(json);
  }
}

export class RelayPool {
  relayByUrl: Map<string, Relay> = new Map();
  noticecbs: Array<(url: string, msg: string) => void> = [];
  errorcbs: Array<(url: string, err: string) => void> = [];
  authcbs: Array<(relay: Relay, challenge: string) => void> = [];
  eventCache?: EventCache;
  minMaxDelayms: number = Infinity;
  filtersToSubscribe: FilterToSubscribe[] = [];
  timer?: ReturnType<typeof setTimeout>;
  externalGetEventById?: (id: string) => Event | undefined;
  logSubscriptions?: boolean = false;
  autoReconnect?: boolean = false;
  startTime: number = new Date().getTime();
  deleteSignatures?: boolean;
  subscriptionCache?: Map<
    string,
    CallbackReplayer<[Event, boolean, string | undefined], OnEvent>
  >;
  skipVerification?: boolean;
  writeRelays: NewestEventCache;
  metadataCache: NewestEventCache;
  contactListCache: NewestEventCache;
  logErrorsAndNotices?: boolean;
  errorsAndNoticesInterval: any;

  constructor(
    relays?: string[],
    options: {
      useEventCache?: boolean;
      externalGetEventById?: (id: string) => Event | undefined;
      logSubscriptions?: boolean;
      autoReconnect?: boolean;
      subscriptionCache?: boolean;
      deleteSignatures?: boolean;
      skipVerification?: boolean;
      logErrorsAndNotices?: boolean;
    } = {}
  ) {
    this.externalGetEventById = options.externalGetEventById;
    this.logSubscriptions = options.logSubscriptions;
    this.autoReconnect = options.autoReconnect;
    this.deleteSignatures = options.deleteSignatures;
    this.skipVerification = options.skipVerification;
    this.writeRelays = new NewestEventCache(10003, this, undefined, true);
    this.metadataCache = new NewestEventCache(0, this);
    this.contactListCache = new NewestEventCache(3, this);
    if (options.useEventCache) {
      this.eventCache = new EventCache();
    }
    if (options.subscriptionCache) {
      this.subscriptionCache = new Map();
    }
    if (relays) {
      for (const relay of unique(relays)) {
        this.addOrGetRelay(relay);
      }
    }

    this.logErrorsAndNotices = options.logErrorsAndNotices ?? true;

    this.onnotice((url, msg) => {
      this.errorsAndNotices.push({
        type: "notice",
        url,
        msg,
        time: Date.now() - this.startTime,
      });
    });
    this.onerror((url, msg) => {
      this.errorsAndNotices.push({
        type: "error",
        url,
        msg,
        time: Date.now() - this.startTime,
      });
    });
    this.errorsAndNoticesInterval = setInterval(
      () => this.#maybeLogErrorsAndNotices(),
      1000 * 10
    );
  }
  errorsAndNotices: {
    type: string;
    url: string;
    msg: string | Error;
    time: number;
  }[] = [];

  #maybeLogErrorsAndNotices() {
    if (!this.errorsAndNotices.length) {
      return;
    }
    if (!this.logErrorsAndNotices) {
      this.errorsAndNotices = [];
      return;
    }
    if (this.errorsAndNotices.length > 5) {
      console.groupCollapsed(
        "RelayPool errors and notices with " +
          this.errorsAndNotices.length +
          " entries"
      );
    } else {
      console.group("RelayPool errors and notices");
    }
    console.table(this.errorsAndNotices.map((e) => ({...e, msg: e.msg})));
    console.groupEnd();
    this.errorsAndNotices = [];
  }

  addOrGetRelay(relay: string): Relay {
    const origRelayInstance = this.relayByUrl.get(relay);
    if (origRelayInstance) {
      return origRelayInstance;
    }
    const relayInstance = relayInit(
      relay,
      this.externalGetEventById
        ? this.externalGetEventById
        : this.eventCache
        ? (id) => this.eventCache?.getEventById(id)
        : undefined,
      this.autoReconnect
    );
    this.relayByUrl.set(relay, relayInstance);
    relayInstance.connect().then(
      (onfulfilled) => {
        relayInstance?.on("notice", (msg: string) => {
          this.noticecbs.forEach((cb) => cb(relay, msg));
        });
        relayInstance?.on("auth", (msg: string) => {
          this.authcbs.forEach((cb) => cb(relayInstance, msg));
        });
      },
      (onrejected) => {
        this.errorcbs.forEach((cb) => cb(relay, onrejected));
      }
    );
    return relayInstance;
  }

  async close() {
    const promises = [];
    for (const relayInstance of this.relayByUrl.values()) {
      promises.push(relayInstance.close());
    }
    this.relayByUrl.clear();
    clearInterval(this.errorsAndNoticesInterval);
    return Promise.all(promises);
  }

  removeRelay(url: string) {
    const relay = this.relayByUrl.get(url);
    if (relay) {
      relay.close();
      this.relayByUrl.delete(url);
    }
  }

  #subscribeRelay(
    relay: string,
    filters: Filter[],
    onEvent: OnEvent,
    onEose?: OnEose,
    eventIds?: Set<string>
  ): Sub | undefined {
    const mergedAndRemovedEmptyFilters =
      mergeSimilarAndRemoveEmptyFilters(filters);
    if (mergedAndRemovedEmptyFilters.length === 0) {
      return;
    }
    const instance = this.addOrGetRelay(relay);
    const sub = instance.sub(mergedAndRemovedEmptyFilters, {
      skipVerification: this.skipVerification,
      eventIds,
    });
    let afterEose = false;
    let minCreatedAt = Infinity;
    sub.on("event", (nostrEvent: Event) => {
      if (nostrEvent.created_at < minCreatedAt) {
        minCreatedAt = nostrEvent.created_at;
      }
      let event = nostrEvent;
      if (!this.deleteSignatures) {
        event.sig = nostrEvent.sig;
      }
      this.eventCache?.addEvent(event);
      onEvent(event, afterEose, relay);
    });
    sub.on("eose", () => {
      onEose?.(relay, minCreatedAt);
      afterEose = true;
    });

    return sub;
  }

  #mergeAndRemoveEmptyFiltersByRelay(
    filtersByRelay: Map<string, Filter[]>
  ): Map<string, Filter[]> {
    const mergedAndRemovedEmptyFiltersByRelay = new Map();
    for (const [relay, filters] of filtersByRelay) {
      const mergedAndRemovedEmptyFilters = mergeSimilarAndRemoveEmptyFilters(
        mergeSimilarAndRemoveEmptyFilters(filters)
      );
      if (mergedAndRemovedEmptyFilters.length > 0) {
        mergedAndRemovedEmptyFiltersByRelay.set(
          relay,
          mergedAndRemovedEmptyFilters
        );
      }
    }
    return mergedAndRemovedEmptyFiltersByRelay;
  }

  #subscribeRelays(
    filtersByRelay: Map<string, Filter[]>,
    onEvent: OnEvent,
    onEose?: OnEose,
    unsub: {unsubcb?: () => void; unsuboneosecb?: () => void} = {},
    minMaxDelayms?: number
  ): () => void {
    if (filtersByRelay.size === 0) {
      return () => {};
    }
    // Merging here is done to make logging more readable.
    filtersByRelay = this.#mergeAndRemoveEmptyFiltersByRelay(filtersByRelay);
    if (this.logSubscriptions) {
      // console.log(
      //   "RelayPool subscribeRelays: at time",
      //   new Date().getTime() - this.startTime,
      //   "ms, minMaxDelayms=",
      //   minMaxDelayms,
      //   ", filtersByRelay: ",
      //   filtersByRelay
      // );
      console.group("RelayPool subscribeRelays");
      console.log(
        "at time",
        new Date().getTime() - this.startTime,
        "ms, minMaxDelayms=",
        minMaxDelayms
      );
      const flattenedFilters: any = {};
      for (const [relay, filters] of filtersByRelay) {
        let i = 0;
        for (const filter of filters) {
          const filter2: any = {...filter};
          if (filter2.authors) {
            filter2.authors = filter2.authors.join();
            filter2["authors.length"] = filter?.authors?.length;
          }
          if (filter2.kinds) {
            filter2.kinds = filter2.kinds.join();
          }
          if (filter2.ids) {
            filter2.ids = filter2.ids.join();
            filter2["ids.length"] = filter?.ids?.length;
          }
          if (filter2["#e"]) {
            filter2["#e"] = filter2["#e"].join();
            filter2["#e.length"] = filter["#e"]!.length;
          }
          if (filter2["#p"]) {
            filter2["#p"] = filter2["#p"].join();
            filter2["#p.length"] = filter["#p"]!.length;
          }
          flattenedFilters[relay + " " + i] = filter2;
          i++;
        }
      }

      if (Object.keys(flattenedFilters).length > 3) {
        console.groupCollapsed(
          Object.keys(flattenedFilters).length +
            " filters to " +
            filtersByRelay.size +
            " relays"
        );
      }
      console.table(flattenedFilters);
      if (Object.keys(flattenedFilters).length > 3) {
        console.groupEnd();
      }
      console.groupEnd();
    }
    const subs: Sub[] = [];
    let unsuboneosecbcalled = false;
    let eoseSubs: Sub[] = [];
    unsub.unsuboneosecb = () => {
      unsuboneosecbcalled = true;
      eoseSubs.forEach((sub) => sub.unsub());
    };
    for (const [relay, filters] of filtersByRelay) {
      let subHolder: {sub?: Sub} = {};
      const subOnEose: OnEose = (url, minCreatedAt) => {
        if (onEose) {
          onEose(url, minCreatedAt);
        }
        if (unsuboneosecbcalled) {
          subHolder.sub?.unsub();
        } else {
          if (subHolder.sub) {
            eoseSubs.push(subHolder.sub);
          }
        }
      };

      const eventIds = new Set<string>();

      const sub = this.#subscribeRelay(
        relay,
        filters,
        onEvent,
        subOnEose,
        eventIds
      );
      if (sub) {
        subHolder.sub = sub;
        subs.push(sub);
      }
    }
    const allUnsub = () => subs.forEach((sub) => sub.unsub());
    unsub.unsubcb = () => {
      allUnsub();
      delete unsub.unsubcb;
    };
    return allUnsub;
  }

  sendSubscriptions(onEose?: OnEose, filtersToSubscribe?: FilterToSubscribe[]) {
    clearTimeout(this.timer);
    this.timer = undefined;
    let minMaxDelayms = this.minMaxDelayms;
    this.minMaxDelayms = Infinity;

    const [onEvent, filtersByRelay, unsub]: [
      OnEvent,
      Map<string, Filter[]>,
      {unsubcb?: () => void; unsuboneosecb?: () => void}
    ] = batchFiltersByRelay(
      filtersToSubscribe || this.filtersToSubscribe,
      this.subscriptionCache
    );

    let allUnsub = this.#subscribeRelays(
      filtersByRelay,
      onEvent,
      onEose,
      unsub,
      minMaxDelayms // For logging
    );

    return allUnsub;
  }

  #resetTimer(maxDelayms: number) {
    if (this.minMaxDelayms > maxDelayms) {
      this.minMaxDelayms = maxDelayms;
    }

    clearTimeout(this.timer);
    this.timer = undefined;

    if (this.minMaxDelayms !== Infinity) {
      this.timer = setTimeout(() => {
        this.sendSubscriptions();
      }, this.minMaxDelayms);
    }
  }

  async #getRelaysAndSubscribe(
    filters: (Filter & {relay?: string; noCache?: boolean})[],
    onEvent: OnEvent,
    maxDelayms?: number,
    onEose?: OnEose,
    options: SubscriptionOptions = {}
  ) {
    const allAuthors: Set<string> = new Set();
    for (const filter of filters) {
      if (filter.authors) {
        for (const author of filter.authors) {
          allAuthors.add(author);
        }
      } else {
        if (!options.defaultRelays) {
          throw new Error(
            "Authors must be specified if no relays are subscribed and no default relays are specified."
          );
        }
      }
    }
    const promises = [];
    const allAuthorsArray = [];
    for (const author of allAuthors) {
      promises.push(
        this.writeRelays
          ?.get(author)
          .then((event) => parseJSON(event?.content))
          .catch(() => options?.defaultRelays || [])
      );
      allAuthorsArray.push(author);
    }
    const allRelays: Set<string> = new Set();
    let i = 0;
    for (const promise of promises) {
      const author = allAuthorsArray[i];
      i += 1;
      let relays = await promise;
      if (!Array.isArray(relays)) {
        console.error("Couldn't load relays for author ", author);
        continue;
      }
      for (let relay of relays) {
        allRelays.add(relay);
      }
    }
    let allRelaysArray = Array.from(allRelays);
    if (allRelaysArray.length === 0) {
      if (options.defaultRelays) {
        allRelaysArray = options.defaultRelays;
      }
    }
    // if (this.logSubscriptions) {
    //   console.log(
    //     "getRelaysAndSubscribe",
    //     "filters=",
    //     filters,
    //     "allRelaysArray=",
    //     allRelaysArray,
    //     "maxDelayms=",
    //     maxDelayms,
    //     "options=",
    //     options
    //   );
    // }

    return this.subscribe(
      filters,
      allRelaysArray,
      onEvent,
      maxDelayms,
      onEose,
      options
    );
  }

  subscribeEventObject(
    filters: (Filter & {relay?: string; noCache?: boolean})[],
    relays: string[] | undefined,
    onEventObject: OnEventObject,
    maxDelayms?: number,
    onEose?: OnEose,
    options: SubscriptionOptions = {}
  ): () => void {
    return this.subscribe(filters, relays, (event, afterEose, url) =>
      onEventObject(new EventObject(event, this, relays), afterEose, url)
    );
  }

  subscribe(
    filters: (Filter & {relay?: string; noCache?: boolean})[],
    relays: string[] | undefined,
    onEvent: OnEvent,
    maxDelayms?: number,
    onEose?: OnEose,
    options: SubscriptionOptions = {}
  ): () => void {
    if (maxDelayms !== undefined && onEose) {
      throw new Error("maxDelayms and onEose cannot be used together");
    }
    if (relays === undefined) {
      const promise = this.#getRelaysAndSubscribe(
        filters,
        onEvent,
        maxDelayms,
        onEose,
        options
      );
      return () => {
        promise.then((x) => {
          x();
        });
      };
    }
    let subscriptionCacheKey: string | undefined;
    if (options.unsubscribeOnEose && !onEose) {
      subscriptionCacheKey = JSON.stringify([filters, relays]);
      const cachedSubscription =
        this.subscriptionCache?.get(subscriptionCacheKey);
      if (cachedSubscription) {
        return cachedSubscription.sub(onEvent);
      }
    }
    const [dedupedOnEvent, filtersByRelay] =
      groupFiltersByRelayAndEmitCacheHits(
        filters,
        relays,
        onEvent,
        options,
        this.eventCache
      );
    let unsub: {unsubcb?: () => void} = {unsubcb: () => {}};
    if (
      maxDelayms === undefined &&
      onEose &&
      this.filtersToSubscribe.length > 0 &&
      !options.dontSendOtherFilters
    ) {
      this.sendSubscriptions(); // onEose is not yet supported for batched subscriptions
    }
    const newFilters: FilterToSubscribe = [
      dedupedOnEvent,
      filtersByRelay,
      unsub,
      options.unsubscribeOnEose,
      subscriptionCacheKey,
      maxDelayms,
    ];
    if (options.dontSendOtherFilters) {
      return this.sendSubscriptions(onEose, [newFilters]);
    }

    this.filtersToSubscribe.push(newFilters);
    if (maxDelayms === undefined) {
      return this.sendSubscriptions(onEose);
    } else {
      this.#resetTimer(maxDelayms);
      return () => {
        unsub.unsubcb?.();
        delete unsub.unsubcb;
      };
    }
  }

  async getEventObjectById(
    id: string,
    relays: string[],
    maxDelayms: number
  ): Promise<EventObject> {
    return this.getEventById(id, relays, maxDelayms).then(
      (event) => new EventObject(event, this, relays)
    );
  }

  async getEventById(
    id: string,
    relays: string[],
    maxDelayms: number
  ): Promise<Event> {
    return new Promise((resolve, reject) => {
      this.subscribe(
        [{ids: [id]}],
        relays,
        (event) => {
          resolve(event);
        },
        maxDelayms,
        undefined
        // {unsubscribeOnEose: true}
      );
    });
  }

  publish(event: Event, relays: string[]) {
    for (const relay of unique(relays)) {
      const instance = this.addOrGetRelay(relay);
      instance.publish(event);
    }
  }

  onnotice(cb: (url: string, msg: string) => void) {
    this.noticecbs.push(cb);
  }

  onerror(cb: (url: string, msg: string) => void) {
    this.relayByUrl.forEach((relay: Relay, url: string) =>
      relay.on("error", (msg: string) => cb(url, msg))
    );
    this.errorcbs.push(cb);
  }
  ondisconnect(cb: (url: string, msg: string) => void) {
    this.relayByUrl.forEach((relay: Relay, url: string) =>
      relay.on("disconnect", (msg: string) => cb(url, msg))
    );
  }
  onauth(cb: (relay: Relay, challenge: string) => void) {
    this.authcbs.push(cb);
  }
  getRelayStatuses(): [url: string, staus: number][] {
    return Array.from(this.relayByUrl.entries())
      .map(
        ([url, relay]: [string, Relay]) =>
          [url, relay.status] as [string, number]
      )
      .sort();
  }
  setWriteRelaysForPubKey(
    pubkey: string,
    writeRelays: string[],
    created_at: number
  ) {
    const event: Event = {
      created_at,
      pubkey: "",
      id: "",
      sig: "",
      content: JSON.stringify(writeRelays),
      // @ts-ignore
      kind: 10003,
      tags: [["p", pubkey]],
    };
    this.writeRelays.data.set(pubkey, event);
  }
  setCachedMetadata(pubkey: string, metadata: Event) {
    this.metadataCache.data.set(pubkey, metadata);
  }
  setCachedContactList(pubkey: string, contactList: Event) {
    this.contactListCache.data.set(pubkey, contactList);
  }

  subscribeReferencedEvents(
    event: Event,
    onEvent: OnEvent,
    maxDelayms?: number,
    onEose?: OnEose,
    options: SubscriptionOptions = {}
  ): () => void {
    let ids: string[] = [];
    let authors: string[] = [];

    for (const tag of event.tags) {
      if (tag[0] === "p") {
        const pubkey = tag[1];
        if (pubkey.length !== 64) {
          console.log("bad pubkey", pubkey, tag);
          continue;
        }
        authors.push(pubkey);
      }
      if (tag[0] === "e") {
        const id = tag[1];
        ids.push(id);
      }
    }
    if (ids.length === 0) {
      return () => {};
    }
    if (authors.length === 0) {
      if (options.defaultRelays) {
        return this.subscribe(
          [{ids}],
          options.defaultRelays,
          onEvent,
          maxDelayms,
          onEose,
          options
        );
      } else {
        console.error("No authors for ids in event", event);
        return () => {};
      }
    }
    if (this.logSubscriptions) {
      // console.log("subscribeReferencedEvents0: ", ids, authors);
    }
    return this.subscribe(
      [{ids, authors}],
      undefined,
      onEvent,
      maxDelayms,
      onEose,
      options
    );
  }

  fetchAndCacheMetadata(pubkey: string): Promise<Event> {
    return this.metadataCache.get(pubkey).catch((e) => {
      this.errorsAndNotices.push({
        type: "error",
        msg: `Error fetching metadata for ${pubkey}: ${e}`,
        time: Date.now() - this.startTime,
        url: "",
      });

      throw new Error(`Error fetching metadata for ${pubkey}: ${e}`);
    });
  }

  fetchAndCacheContactList(pubkey: string): Promise<Event> {
    return this.contactListCache.get(pubkey).catch((e) => {
      this.errorsAndNotices.push({
        type: "error",
        msg: `Error fetching contact list for ${pubkey}: ${e}`,
        time: Date.now() - this.startTime,
        url: "",
      });
      throw new Error(`Error fetching contact list for ${pubkey}: ${e}`);
    });
  }

  subscribeReferencedEventsAndPrefetchMetadata(
    event: Event,
    onEvent: OnEvent,
    maxDelayms?: number,
    onEose?: OnEose,
    options: SubscriptionOptions = {}
  ): () => void {
    for (const tag of event.tags) {
      if (tag[0] === "p") {
        const pubkey = tag[1];
        if (pubkey.length !== 64) {
          console.log("bad pubkey", pubkey, tag);
          continue;
        }
        this.fetchAndCacheMetadata(pubkey).catch((e) => {
          this.errorsAndNotices.push({
            type: "error",
            url: "",
            msg: `Error fetching metadata for ${pubkey}: ${e}`,
            time: Date.now() - this.startTime,
          });
        });
      }
    }
    return this.subscribeReferencedEvents(
      event,
      onEvent,
      maxDelayms,
      onEose,
      options
    );
  }

  reconnect() {
    this.relayByUrl.forEach((relay: Relay) => {
      relay.connect().catch((e) => {
        this.errorsAndNotices.push({
          type: "error",
          msg: `Error reconnecting to ${relay.url}: ${e}`,
          time: Date.now() - this.startTime,
          url: relay.url,
        });
      });
    });
  }
}
