import NDK, {
  NDKEvent,
  NDKNip46Signer,
  NDKPrivateKeySigner,
  NDKUser,
  NostrEvent
} from '@nostr-dev-kit/ndk'
import {
  Event,
  EventTemplate,
  UnsignedEvent,
  finalizeEvent,
  nip04,
  nip19,
  nip44
} from 'nostr-tools'
import { EventEmitter } from 'tseep'
import { updateNsecbunkerPubkey } from '../store/actions'
import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store'
import { SignedEvent } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'

export class NostrController extends EventEmitter {
  private static instance: NostrController

  private bunkerNDK: NDK | undefined
  private remoteSigner: NDKNip46Signer | undefined

  private constructor() {
    super()
  }

  private getNostrObject = () => {
    // fix: this is not picking up type declaration from src/system/index.d.ts
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    if (window.nostr) return window.nostr as any

    throw new Error(
      `window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
    )
  }

  public nsecBunkerInit = async (relays: string[]) => {
    // Don't reinstantiate bunker NDK if exists with same relays
    if (
      this.bunkerNDK &&
      this.bunkerNDK.explicitRelayUrls?.length === relays.length &&
      this.bunkerNDK.explicitRelayUrls?.every((relay) => relays.includes(relay))
    )
      return

    this.bunkerNDK = new NDK({
      explicitRelayUrls: relays
    })

    try {
      await this.bunkerNDK
        .connect(2000)
        .then(() => {
          console.log(
            `Successfully connected to the nsecBunker relays: ${relays.join(
              ','
            )}`
          )
        })
        .catch((err) => {
          console.error(
            `Error connecting to the nsecBunker relays: ${relays.join(
              ','
            )} ${err}`
          )
        })
    } catch (err) {
      console.error(err)
    }
  }

  /**
   * Creates nSecBunker signer instance for the given npub
   * Or if npub omitted it will return existing signer
   * If neither, error will be thrown
   * @param npub nPub / public key in hex format
   * @returns nsecBunker Signer instance
   */
  public createNsecBunkerSigner = async (
    npub: string | undefined
  ): Promise<NDKNip46Signer> => {
    const nsecBunkerDelegatedKey = getNsecBunkerDelegatedKey()

    return new Promise((resolve, reject) => {
      if (!nsecBunkerDelegatedKey) {
        reject('nsecBunker delegated key is not found in the browser.')
        return
      }
      const localSigner = new NDKPrivateKeySigner(nsecBunkerDelegatedKey)

      if (!npub) {
        if (this.remoteSigner) resolve(this.remoteSigner)

        const npubFromStorage = (store.getState().auth as AuthState)
          .nsecBunkerPubkey

        if (npubFromStorage) {
          npub = npubFromStorage
        } else {
          reject(
            'No signer instance present, no npub provided by user or found in the browser.'
          )
          return
        }
      } else {
        store.dispatch(updateNsecbunkerPubkey(npub))
      }

      // Pubkey of a key pair stored in nsecbunker that will be used to sign event with
      const appPubkeyOrToken = npub.includes('npub')
        ? npub
        : nip19.npubEncode(npub)

      /**
       * When creating and NDK instance we create new connection to the relay
       * To prevent too much connections and hitting rate limits, if npub against which we sign
       * we will reuse existing instance. Otherwise we will create new NDK and signer instance.
       */
      if (!this.remoteSigner || this.remoteSigner?.remotePubkey !== npub) {
        this.remoteSigner = new NDKNip46Signer(
          this.bunkerNDK!,
          appPubkeyOrToken,
          localSigner
        )
      }

      /**
       * when nsecbunker-delegated-key is regenerated we have to reinitialize the remote signer
       */
      if (this.remoteSigner.localSigner !== localSigner) {
        this.remoteSigner = new NDKNip46Signer(
          this.bunkerNDK!,
          appPubkeyOrToken,
          localSigner
        )
      }

      resolve(this.remoteSigner)
    })
  }

  /**
   * Signs the nostr event and returns the sig and id or full raw nostr event
   * @param npub stored in nsecBunker to sign with
   * @param event to be signed
   * @param returnFullEvent whether to return full raw nostr event or just SIG and ID values
   */
  public signWithNsecBunker = async (
    npub: string | undefined,
    event: NostrEvent,
    returnFullEvent = true
  ): Promise<{ id: string; sig: string } | NostrEvent> => {
    return new Promise((resolve, reject) => {
      this.createNsecBunkerSigner(npub)
        .then(async (signer) => {
          const ndkEvent = new NDKEvent(undefined, event)

          const timeout = setTimeout(() => {
            reject('Timeout occurred while waiting for event signing')
          }, 60000) // 60000 ms (1 min) = 1000 * 60

          await ndkEvent.sign(signer).catch((err) => {
            clearTimeout(timeout)
            reject(err)
            return
          })

          clearTimeout(timeout)

          if (returnFullEvent) {
            resolve(ndkEvent.rawEvent())
          } else {
            resolve({
              id: ndkEvent.id,
              sig: ndkEvent.sig!
            })
          }
        })
        .catch((err) => {
          reject(err)
        })
    })
  }

  public static getInstance(): NostrController {
    if (!NostrController.instance) {
      NostrController.instance = new NostrController()
    }
    return NostrController.instance
  }

  /**
   * Encrypts the given content for the specified receiver using NIP-44 encryption.
   *
   * @param receiver The public key of the receiver.
   * @param content The content to be encrypted.
   * @returns The encrypted content as a string.
   * @throws Error if the nostr extension does not support NIP-44, if the private key pair is not found, or if the login method is unsupported.
   */
  nip44Encrypt = async (receiver: string, content: string) => {
    // Retrieve the current login method from the application's redux state.
    const loginMethod = (store.getState().auth as AuthState).loginMethod

    // Handle encryption when the login method is via an extension.
    if (loginMethod === LoginMethods.extension) {
      const nostr = this.getNostrObject()

      // Check if the nostr object supports NIP-44 encryption.
      if (!nostr.nip44) {
        throw new Error(
          `Your nostr extension does not support nip44 encryption & decryption`
        )
      }

      // Encrypt the content using NIP-44 provided by the nostr extension.
      const encrypted = await nostr.nip44.encrypt(receiver, content)
      return encrypted as string
    }

    // Handle encryption when the login method is via a private key.
    if (loginMethod === LoginMethods.privateKey) {
      const keys = (store.getState().auth as AuthState).keyPair

      // Check if the private and public key pair is available.
      if (!keys) {
        throw new Error(
          `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
        )
      }

      // Decode the private key.
      const { private: nsec } = keys
      const privateKey = nip19.decode(nsec).data as Uint8Array

      // Generate the conversation key using NIP-44 utilities.
      const nip44ConversationKey = nip44.v2.utils.getConversationKey(
        privateKey,
        receiver
      )

      // Encrypt the content using the generated conversation key.
      const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)

      return encrypted
    }

    // Throw an error if the login method is nsecBunker (not supported).
    if (loginMethod === LoginMethods.nsecBunker) {
      throw new Error(
        `nip44 encryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
      )
    }

    // Throw an error if the login method is undefined or unsupported.
    throw new Error('Login method is undefined')
  }

  /**
   * Decrypts the given content from the specified sender using NIP-44 decryption.
   *
   * @param sender The public key of the sender.
   * @param content The encrypted content to be decrypted.
   * @returns The decrypted content as a string.
   * @throws Error if the nostr extension does not support NIP-44, if the private key pair is not found, or if the login method is unsupported.
   */
  nip44Decrypt = async (sender: string, content: string) => {
    // Retrieve the current login method from the application's redux state.
    const loginMethod = (store.getState().auth as AuthState).loginMethod

    // Handle decryption when the login method is via an extension.
    if (loginMethod === LoginMethods.extension) {
      const nostr = this.getNostrObject()

      // Check if the nostr object supports NIP-44 decryption.
      if (!nostr.nip44) {
        throw new Error(
          `Your nostr extension does not support nip44 encryption & decryption`
        )
      }

      // Decrypt the content using NIP-44 provided by the nostr extension.
      const decrypted = await nostr.nip44.decrypt(sender, content)
      return decrypted as string
    }

    // Handle decryption when the login method is via a private key.
    if (loginMethod === LoginMethods.privateKey) {
      const keys = (store.getState().auth as AuthState).keyPair

      // Check if the private and public key pair is available.
      if (!keys) {
        throw new Error(
          `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
        )
      }

      // Decode the private key.
      const { private: nsec } = keys
      const privateKey = nip19.decode(nsec).data as Uint8Array

      // Generate the conversation key using NIP-44 utilities.
      const nip44ConversationKey = nip44.v2.utils.getConversationKey(
        privateKey,
        sender
      )

      // Decrypt the content using the generated conversation key.
      const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)

      return decrypted
    }

    // Throw an error if the login method is nsecBunker (not supported).
    if (loginMethod === LoginMethods.nsecBunker) {
      throw new Error(
        `nip44 decryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
      )
    }

    // Throw an error if the login method is undefined or unsupported.
    throw new Error('Login method is undefined')
  }

  /**
   * Signs an event with private key (if it is present in local storage) or
   * with browser extension (if it is present) or
   * with nSecBunker instance.
   * @param event - unsigned nostr event.
   * @returns - a promised that is resolved with signed nostr event.
   */
  signEvent = async (
    event: UnsignedEvent | EventTemplate
  ): Promise<SignedEvent> => {
    const loginMethod = (store.getState().auth as AuthState).loginMethod

    if (!loginMethod) {
      return Promise.reject('No login method found in the browser storage')
    }

    if (loginMethod === LoginMethods.nsecBunker) {
      // Check if nsecBunker is available
      if (!this.bunkerNDK) {
        return Promise.reject(
          `Login method is ${loginMethod} but bunkerNDK is not created`
        )
      }

      if (!this.remoteSigner) {
        return Promise.reject(
          `Login method is ${loginMethod} but bunkerNDK is not created`
        )
      }

      const signedEvent = await this.signWithNsecBunker(
        '',
        event as NostrEvent
      ).catch((err) => {
        throw err
      })

      return Promise.resolve(signedEvent as SignedEvent)
    } else if (loginMethod === LoginMethods.privateKey) {
      const keys = (store.getState().auth as AuthState).keyPair

      if (!keys) {
        return Promise.reject(
          `Login method is ${loginMethod}, but keys are not found`
        )
      }

      const { private: nsec } = keys
      const privateKey = nip19.decode(nsec).data as Uint8Array

      const signedEvent = finalizeEvent(event, privateKey)

      verifySignedEvent(signedEvent)

      return Promise.resolve(signedEvent)
    } else if (loginMethod === LoginMethods.extension) {
      const nostr = this.getNostrObject()

      return (await nostr
        .signEvent(event as NostrEvent)
        .catch((err: unknown) => {
          console.log('Error while signing event: ', err)

          throw err
        })) as Event
    } else {
      return Promise.reject(
        `We could not sign the event, none of the signing methods are available`
      )
    }
  }

  nip04Encrypt = async (receiver: string, content: string): Promise<string> => {
    const loginMethod = (store.getState().auth as AuthState).loginMethod

    if (loginMethod === LoginMethods.extension) {
      const nostr = this.getNostrObject()

      if (!nostr.nip04) {
        throw new Error(
          `Your nostr extension does not support nip04 encryption & decryption`
        )
      }

      const encrypted = await nostr.nip04.encrypt(receiver, content)
      return encrypted
    }

    if (loginMethod === LoginMethods.privateKey) {
      const keys = (store.getState().auth as AuthState).keyPair

      if (!keys) {
        throw new Error(
          `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
        )
      }

      const { private: nsec } = keys
      const privateKey = nip19.decode(nsec).data as Uint8Array

      const encrypted = await nip04.encrypt(privateKey, receiver, content)
      return encrypted
    }

    if (loginMethod === LoginMethods.nsecBunker) {
      const user = new NDKUser({ pubkey: receiver })

      this.remoteSigner?.on('authUrl', (authUrl) => {
        this.emit('nsecbunker-auth', authUrl)
      })

      if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
      const encrypted = await this.remoteSigner.encrypt(user, content)

      return encrypted
    }

    throw new Error('Login method is undefined')
  }

  /**
   * Decrypts a given content based on the current login method.
   *
   * @param sender - The sender's public key.
   * @param content - The encrypted content to decrypt.
   * @returns A promise that resolves to the decrypted content.
   */
  nip04Decrypt = async (sender: string, content: string): Promise<string> => {
    const loginMethod = (store.getState().auth as AuthState).loginMethod

    if (loginMethod === LoginMethods.extension) {
      const nostr = this.getNostrObject()

      if (!nostr.nip04) {
        throw new Error(
          `Your nostr extension does not support nip04 encryption & decryption`
        )
      }

      const decrypted = await nostr.nip04.decrypt(sender, content)
      return decrypted
    }

    if (loginMethod === LoginMethods.privateKey) {
      const keys = (store.getState().auth as AuthState).keyPair

      if (!keys) {
        throw new Error(
          `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
        )
      }

      const { private: nsec } = keys
      const privateKey = nip19.decode(nsec).data as Uint8Array

      const decrypted = await nip04.decrypt(privateKey, sender, content)
      return decrypted
    }

    if (loginMethod === LoginMethods.nsecBunker) {
      const user = new NDKUser({ pubkey: sender })

      this.remoteSigner?.on('authUrl', (authUrl) => {
        this.emit('nsecbunker-auth', authUrl)
      })

      if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
      const decrypted = await this.remoteSigner.decrypt(user, content)

      return decrypted
    }

    throw new Error('Login method is undefined')
  }

  /**
   * Function will capture the public key from the nostr extension or if no extension present
   * function wil capture the public key from the local storage
   */
  capturePublicKey = async (): Promise<string> => {
    const nostr = this.getNostrObject()
    const pubKey = await nostr.getPublicKey().catch((err: unknown) => {
      if (err instanceof Error) {
        return Promise.reject(err.message)
      } else {
        return Promise.reject(JSON.stringify(err))
      }
    })

    if (!pubKey) {
      return Promise.reject('Error getting public key, user canceled')
    }

    return Promise.resolve(pubKey)
  }

  /**
   * Generates NDK Private Signer
   * @returns nSecBunker delegated key
   */
  generateDelegatedKey = (): string => {
    return NDKPrivateKeySigner.generate().privateKey!
  }
}
