import { Event, Filter, Relay } from 'nostr-tools'
import {
  settleAllFullfilfedPromises,
  normalizeWebSocketURL,
  timeout
} from '../utils'
import { SIGIT_RELAY } from '../utils/const'

/**
 * Singleton class to manage relay operations.
 */
export class RelayController {
  private static instance: RelayController
  private pendingConnections = new Map<string, Promise<Relay | null>>() // Track pending connections
  public connectedRelays = new Map<string, Relay>()

  private constructor() {}

  /**
   * Provides the singleton instance of RelayController.
   *
   * @returns The singleton instance of RelayController.
   */
  public static getInstance(): RelayController {
    if (!RelayController.instance) {
      RelayController.instance = new RelayController()
    }
    return RelayController.instance
  }

  /**
   * Connects to a relay server if not already connected.
   *
   * This method checks if a relay with the given URL is already in the list of connected relays.
   * If it is not connected, it attempts to establish a new connection.
   * On successful connection, the relay is added to the list of connected relays and returned.
   * If the connection fails, an error is logged and `null` is returned.
   *
   * @param  relayUrl - The URL of the relay server to connect to.
   * @returns  A promise that resolves to the connected relay object if successful, or `null` if the connection fails.
   */
  public connectRelay = async (relayUrl: string): Promise<Relay | null> => {
    const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl)
    const relay = this.connectedRelays.get(normalizedWebSocketURL)

    if (relay) {
      if (relay.connected) return relay

      // If relay is found in connectedRelay map but not connected,
      // remove it from map and call connectRelay method again
      this.connectedRelays.delete(relayUrl)
      return this.connectRelay(relayUrl)
    }

    // Check if there's already a pending connection for this relay URL
    if (this.pendingConnections.has(relayUrl)) {
      // Return the existing promise to avoid making another connection
      return this.pendingConnections.get(relayUrl)!
    }

    // Create a new connection promise and store it in pendingConnections
    const connectionPromise = Relay.connect(relayUrl)
      .then((relay) => {
        if (relay.connected) {
          // Add the newly connected relay to the connected relays map
          this.connectedRelays.set(relayUrl, relay)

          // Return the newly connected relay
          return relay
        }

        return null
      })
      .catch((err) => {
        // Log an error message if the connection fails
        console.error(`Relay connection failed: ${relayUrl}`, err)

        // Return null to indicate connection failure
        return null
      })
      .finally(() => {
        // Remove the connection from pendingConnections once it settles
        this.pendingConnections.delete(relayUrl)
      })

    this.pendingConnections.set(relayUrl, connectionPromise)
    return connectionPromise
  }

  /**
   * Asynchronously retrieves multiple event from a set of relays based on a provided filter.
   * If no relays are specified, it defaults to using connected relays.
   *
   * @param filter - The filter criteria to find the event.
   * @param relays - An optional array of relay URLs to search for the event.
   * @returns  Returns a promise that resolves with an array of events.
   */
  fetchEvents = async (
    filter: Filter,
    relayUrls: string[] = []
  ): Promise<Event[]> => {
    if (!relayUrls.includes(SIGIT_RELAY)) {
      /**
       * NOTE: To avoid side-effects on external relayUrls array passed as argument
       * re-assigned relayUrls with added sigit relay instead of just appending to same array
       */

      relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
    }

    // connect to all specified relays
    const relays = await settleAllFullfilfedPromises(
      relayUrls,
      this.connectRelay
    )

    // Check if any relays are connected
    if (relays.length === 0) {
      throw new Error('No relay is connected to fetch events!')
    }

    const events: Event[] = []
    const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates

    // Create a promise for each relay subscription
    const subPromises = relays.map((relay) => {
      return new Promise<void>((resolve) => {
        if (!relay.connected) {
          console.log(`${relay.url} : Not connected!`, 'Skipping subscription')
          return resolve()
        }

        // Subscribe to the relay with the specified filter
        const sub = relay.subscribe([filter], {
          // Handle incoming events
          onevent: (e) => {
            // Add the event to the array if it's not a duplicate
            if (!eventIds.has(e.id)) {
              eventIds.add(e.id) // Record the event ID
              events.push(e) // Add the event to the array
            }
          },
          // Handle the End-Of-Stream (EOSE) message
          oneose: () => {
            sub.close() // Close the subscription
            resolve() // Resolve the promise when EOSE is received
          }
        })

        // add a 30 sec of timeout to subscription
        setTimeout(() => {
          if (!sub.closed) {
            sub.close()
            resolve()
          }
        }, 30 * 1000)
      })
    })

    // Wait for all subscriptions to complete
    await Promise.allSettled(subPromises)

    // It is possible that different relays will send different events and events array may contain more events then specified limit in filter
    // To fix this issue we'll first sort these events and then return only limited events
    if (filter.limit) {
      // Sort events by creation date in descending order
      events.sort((a, b) => b.created_at - a.created_at)

      return events.slice(0, filter.limit)
    }

    return events
  }

  /**
   * Asynchronously retrieves an event from a set of relays based on a provided filter.
   * If no relays are specified, it defaults to using connected relays.
   *
   * @param filter - The filter criteria to find the event.
   * @param relays - An optional array of relay URLs to search for the event.
   * @returns Returns a promise that resolves to the found event or null if not found.
   */
  fetchEvent = async (
    filter: Filter,
    relays: string[] = []
  ): Promise<Event | null> => {
    const events = await this.fetchEvents(filter, relays)

    // Sort events by creation date in descending order
    events.sort((a, b) => b.created_at - a.created_at)

    // Return the most recent event, or null if no events were received
    return events[0] || null
  }

  /**
   * Subscribes to events from multiple relays.
   *
   * This method connects to the specified relay URLs and subscribes to events
   * using the provided filter. It handles incoming events through the given
   * `eventHandler` callback and manages the subscription lifecycle.
   *
   * @param filter - The filter criteria to apply when subscribing to events.
   * @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`SIGIT_RELAY`) is added automatically.
   * @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
   *
   */
  subscribeForEvents = async (
    filter: Filter,
    relayUrls: string[] = [],
    eventHandler: (event: Event) => void
  ) => {
    if (!relayUrls.includes(SIGIT_RELAY)) {
      /**
       * NOTE: To avoid side-effects on external relayUrls array passed as argument
       * re-assigned relayUrls with added sigit relay instead of just appending to same array
       */
      relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
    }

    // connect to all specified relays
    const relays = await settleAllFullfilfedPromises(
      relayUrls,
      this.connectRelay
    )

    // Check if any relays are connected
    if (relays.length === 0) {
      throw new Error('No relay is connected to fetch events!')
    }

    const processedEvents: string[] = [] // To keep track of processed events

    // Create a promise for each relay subscription
    const subPromises = relays.map((relay) => {
      return new Promise<void>((resolve) => {
        // Subscribe to the relay with the specified filter
        const sub = relay.subscribe([filter], {
          // Handle incoming events
          onevent: (e) => {
            // Process event only if it hasn't been processed before
            if (!processedEvents.includes(e.id)) {
              processedEvents.push(e.id)
              eventHandler(e) // Call the event handler with the event
            }
          },
          // Handle the End-Of-Stream (EOSE) message
          oneose: () => {
            sub.close() // Close the subscription
            resolve() // Resolve the promise when EOSE is received
          }
        })
      })
    })

    // Wait for all subscriptions to complete
    await Promise.allSettled(subPromises)
  }

  publish = async (
    event: Event,
    relayUrls: string[] = []
  ): Promise<string[]> => {
    if (!relayUrls.includes(SIGIT_RELAY)) {
      /**
       * NOTE: To avoid side-effects on external relayUrls array passed as argument
       * re-assigned relayUrls with added sigit relay instead of just appending to same array
       */
      relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
    }

    // connect to all specified relays
    const relays = await settleAllFullfilfedPromises(
      relayUrls,
      this.connectRelay
    )

    // Check if any relays are connected
    if (relays.length === 0) {
      throw new Error('No relay is connected to publish event!')
    }

    const publishedOnRelays: string[] = [] // List to track which relays successfully published the event

    // Create a promise for publishing the event to each connected relay
    const publishPromises = relays.map(async (relay) => {
      try {
        await Promise.race([
          relay.publish(event), // Publish the event to the relay
          timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long
        ])
        publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
      } catch (err) {
        console.error(`Failed to publish event on relay: ${relay.url}`, err)
      }
    })

    // Wait for all publish operations to complete (either fulfilled or rejected)
    await Promise.allSettled(publishPromises)

    // Return the list of relay URLs where the event was published
    return publishedOnRelays
  }
}

export const relayController = RelayController.getInstance()
