import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19"
import {get, derived} from "svelte/store"
import {
  first,
  sha256,
  append,
  remove,
  poll,
  uniq,
  equals,
  parseJson,
  last,
  simpleCache,
  normalizeUrl,
  nthNe,
} from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
import {PollResponse} from "nostr-tools/kinds"
import {
  DELETE,
  REPORT,
  PROFILE,
  MESSAGING_RELAYS,
  RELAYS,
  FOLLOWS,
  REACTION,
  RELAY_JOIN,
  RELAY_LEAVE,
  ROOMS,
  COMMENT,
  APP_DATA,
  isSignedEvent,
  makeEvent,
  normalizeRelayUrl,
  makeList,
  addToListPublicly,
  removeFromListByPredicate,
  updateList,
  getTag,
  getListTags,
  getRelayTagValues,
  toNostrURI,
  RelayMode,
  getTagValues,
  uploadBlob,
  canUploadBlob,
  encryptFile,
  makeBlossomAuthEvent,
  isPublishedProfile,
  editProfile,
  createProfile,
  uniqTags,
  ManagementMethod,
} from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
  pubkey,
  sign,
  signer,
  session,
  repository,
  publishThunk,
  tagEvent,
  tagPubkey,
  tagEventForReaction,
  nip44EncryptToSelf,
  tagEventForComment,
  tagEventForQuote,
  waitForThunkError,
  getPubkeyRelays,
  userBlossomServerList,
  getThunkError,
  addRoomMember,
  manageRelay,
  getRelay,
} from "@welshman/app"
import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
import {
  SETTINGS,
  PROTECTED,
  INDEXER_RELAYS,
  DEFAULT_BLOSSOM_SERVERS,
  userSpaceUrls,
  userSettingsValues,
  getSetting,
  getSettings,
  userGroupList,
  shouldIgnoreError,
  stripPrefix,
  relaysMostlyRestricted,
  deriveSocket,
  deriveSpaceMembers,
} from "@app/core/state"

// Utils

export const getPubkeyHints = (pubkey: string) => {
  const relays = getPubkeyRelays(pubkey, RelayMode.Write)
  const hints = relays.length ? relays : INDEXER_RELAYS

  return hints
}

export const prependParent = (
  parent: TrustedEvent | undefined,
  {content, tags}: EventContent,
  url?: string,
) => {
  if (parent) {
    const relays = url ? [url] : Router.get().Event(parent).limit(3).getUrls()
    const nevent = nip19.neventEncode({...parent, relays})

    tags = [...tags, tagEventForQuote(parent, url), tagPubkey(parent.pubkey)]
    content = toNostrURI(nevent) + "\n\n" + content
  }

  return {content, tags}
}

// Synchronization

export const broadcastUserData = async (relays: string[]) => {
  const authors = [pubkey.get()!]
  const kinds = [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE]
  const events = repository.query([{kinds, authors}])

  for (const event of events) {
    if (isSignedEvent(event)) {
      await publishThunk({event, relays}).complete
    }
  }
}

// List updates

export const addSpaceMembership = async (url: string) => {
  const list = get(userGroupList) || makeList({kind: ROOMS})
  const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
  const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])

  return publishThunk({event, relays})
}

export const removeSpaceMembership = async (url: string) => {
  const list = get(userGroupList) || makeList({kind: ROOMS})
  const pred = (t: string[]) => normalizeRelayUrl(t[t[0] === "r" ? 1 : 2]) === url
  const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
  const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])

  return publishThunk({event, relays})
}

export const setSpaceMembershipOrder = async (urls: string[]) => {
  const list = get(userGroupList) || makeList({kind: ROOMS})
  const orderedUrls = uniq(urls.map(normalizeRelayUrl))
  const relayTags = list.publicTags.filter(t => t[0] === "r")
  const otherPublicTags = list.publicTags.filter(t => t[0] !== "r")
  const relayTagByUrl = new Map(relayTags.map(t => [normalizeRelayUrl(t[1]), t]))
  const orderedRelayTags = orderedUrls.map(url => relayTagByUrl.get(url) || ["r", url])
  const publicTags = [...orderedRelayTags, ...otherPublicTags]
  const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf)
  const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])

  return publishThunk({event, relays})
}

export const addRoomMembership = async (url: string, h: string) => {
  const list = get(userGroupList) || makeList({kind: ROOMS})
  const newTags = [
    ["r", url],
    ["group", h, url],
  ]
  const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
  const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])

  return publishThunk({event, relays})
}

export const removeRoomMembership = async (url: string, h: string) => {
  const list = get(userGroupList) || makeList({kind: ROOMS})
  const pred = (t: string[]) => equals(["group", h, url], t.slice(0, 3))
  const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
  const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])

  return publishThunk({event, relays})
}

// Relay access

export const canEnforceNip70 = async (url: string) => {
  const socket = Pool.get().get(url)

  await socket.auth.attemptAuth(sign)

  return socket.auth.status !== AuthStatus.None
}

export const attemptRelayAccess = async (url: string, claim = "") => {
  const socket = Pool.get().get(url)

  socket.attemptToOpen()

  await poll({
    signal: AbortSignal.timeout(3000),
    condition: () => socket.status === SocketStatus.Open,
  })

  if (socket.status !== SocketStatus.Open) {
    return `Failed to connect`
  }

  await socket.auth.attemptAuth(sign)

  // Only raise an error if it's not a timeout.
  // If it is, odds are the problem is with our signer, not the relay
  if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
    if (socket.auth.details) {
      return `Failed to authenticate (${socket.auth.details})`
    } else {
      return `Failed to authenticate (${last(socket.auth.status.split(":"))})`
    }
  }

  const error = await waitForThunkError(publishJoinRequest({url, claim}))

  if (shouldIgnoreError(error)) return
  if (!claim && error.includes("invite code size")) return
  if (error.includes("invite code")) return "join request rejected"

  return stripPrefix(error)
}

export const deriveRelayAuthError = (url: string, claim = "") => {
  // Kick off the auth process
  Pool.get().get(url).auth.attemptAuth(sign)

  // Attempt to join the relay
  const thunk = publishJoinRequest({url, claim})

  return derived(
    [thunk, relaysMostlyRestricted, deriveSocket(url)],
    ([$thunk, $relaysMostlyRestricted, $socket]) => {
      if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) {
        return stripPrefix($socket.auth.details)
      }

      if ($relaysMostlyRestricted[url]) {
        return stripPrefix($relaysMostlyRestricted[url])
      }

      const error = getThunkError($thunk)

      if (error) {
        const isEmptyInvite = !claim && error.includes("invite code")

        if (!shouldIgnoreError(error) && !isEmptyInvite) {
          return stripPrefix(error) || "join request rejected"
        }
      }
    },
  )
}

// Deletions

export type DeleteParams = {
  protect: boolean
  event: TrustedEvent
  tags?: string[][]
}

export const makeDelete = ({protect, event, tags = []}: DeleteParams) => {
  const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags]
  const groupTag = getTag("h", event.tags)

  if (groupTag) {
    thisTags.push(groupTag)
  }

  if (protect) {
    thisTags.push(PROTECTED)
  }

  return makeEvent(DELETE, {tags: thisTags})
}

export const publishDelete = ({relays, ...params}: DeleteParams & {relays: string[]}) =>
  publishThunk({event: makeDelete(params), relays})

// Reports

export type ReportParams = {
  event: TrustedEvent
  content: string
  reason: string
}

export const makeReport = ({event, reason, content}: ReportParams) => {
  const tags = [
    ["p", event.pubkey],
    ["e", event.id, reason],
  ]

  return makeEvent(REPORT, {content, tags})
}

export const publishReport = ({
  relays,
  event,
  reason,
  content,
}: ReportParams & {relays: string[]}) =>
  publishThunk({event: makeReport({event, reason, content}), relays})

// Reactions

export type ReactionParams = {
  protect: boolean
  event: TrustedEvent
  content: string
  url?: string
  tags?: string[][]
}

export const makeReaction = ({
  url,
  protect,
  content,
  event,
  tags: paramTags = [],
}: ReactionParams) => {
  const tags = [...paramTags, ...tagEventForReaction(event, url)]
  const groupTag = getTag("h", event.tags)

  if (groupTag) {
    tags.push(groupTag)
  }

  if (protect) {
    tags.push(PROTECTED)
  }

  return makeEvent(REACTION, {content, tags})
}

export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) => {
  publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
}

// Polls

export type PollResponseParams = {
  event: TrustedEvent
  selectedIds: string[]
}

export const makePollResponse = ({event, selectedIds}: PollResponseParams) =>
  makeEvent(PollResponse, {
    content: "",
    tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])],
  })

export const publishPollResponse = ({relays, ...params}: PollResponseParams & {relays: string[]}) =>
  publishThunk({event: makePollResponse(params), relays})

// Comments

export type CommentParams = {
  event: TrustedEvent
  content: string
  tags?: string[][]
  url?: string
}

export const makeComment = ({url, event, content, tags = []}: CommentParams) =>
  makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]})

export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
  publishThunk({event: makeComment({url: relays[0], ...params}), relays})

// Settings

export const makeSettings = async (params: Partial<SettingsValues>) => {
  const json = JSON.stringify({...get(userSettingsValues), ...params})
  const content = await signer.get().nip44.encrypt(pubkey.get()!, json)
  const tags = [["d", SETTINGS]]

  return makeEvent(APP_DATA, {content, tags})
}

export const publishSettings = async (params: Partial<SettingsValues>) =>
  publishThunk({event: await makeSettings(params), relays: Router.get().FromUser().getUrls()})

export const addTrustedRelay = async (url: string) =>
  publishSettings({trusted_relays: append(url, getSetting<string[]>("trusted_relays"))})

export const removeTrustedRelay = async (url: string) =>
  publishSettings({trusted_relays: remove(url, getSetting<string[]>("trusted_relays"))})

// Space and room notification settings

export const setSpaceNotifications = async (url: string, notify: boolean) => {
  const {alerts} = getSettings()
  const existing = alerts.find((s: SpaceNotificationSettings) => s.url === url)

  let updated: typeof alerts

  if (existing) {
    // Clear exceptions when changing the space notification setting
    updated = alerts.map((s: SpaceNotificationSettings) =>
      s.url === url ? {...s, notify, exceptions: []} : s,
    )
  } else {
    updated = [...alerts, {url, notify, exceptions: []}]
  }

  return publishSettings({alerts: updated})
}

export const toggleRoomNotifications = async (url: string, h: string) => {
  const {alerts} = getSettings()
  const existing = alerts.find((s: SpaceNotificationSettings) => s.url === url)

  let updated: typeof alerts

  if (!existing) {
    updated = [...alerts, {url, notify: true, exceptions: [h]}]
  } else {
    const exceptions = existing.exceptions.includes(h)
      ? remove(h, existing.exceptions)
      : append(h, existing.exceptions)

    updated = alerts.map((s: SpaceNotificationSettings) => (s.url === url ? {...s, exceptions} : s))
  }

  return publishSettings({alerts: updated})
}

// Join request

export type JoinRequestParams = {
  url: string
  claim: string
}

export const makeJoinRequest = (params: JoinRequestParams) =>
  makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})

export const publishJoinRequest = (params: JoinRequestParams) =>
  publishThunk({event: makeJoinRequest(params), relays: [params.url]})

// Leave request

export type LeaveRequestParams = {
  url: string
}

export const publishLeaveRequest = (params: LeaveRequestParams) =>
  publishThunk({event: makeEvent(RELAY_LEAVE), relays: [params.url]})

// Lightning

export const getWebLn = () => (window as any).webln

export const getNwcClient = () => {
  const $session = session.get()

  if (!$session?.wallet || $session.wallet.type !== "nwc") {
    throw new Error("No NWC wallet is connected")
  }

  const {info} = $session.wallet

  if (info.nostrWalletConnectUrl) {
    return new nwc.NWCClient({nostrWalletConnectUrl: info.nostrWalletConnectUrl})
  }

  return new nwc.NWCClient(info)
}

export const payInvoice = async (invoice: string, msats?: number) => {
  const $session = session.get()

  if (!$session?.wallet) {
    throw new Error("No wallet is connected")
  }

  if ($session.wallet.type === "nwc") {
    const params: {invoice: string; amount?: number} = {invoice}
    if (msats) params.amount = msats
    return getNwcClient().payInvoice(params)
  } else if ($session.wallet.type === "webln") {
    if (msats) throw new Error("Unable to pay zero invoices with webln")
    return getWebLn()
      .enable()
      .then(() => getWebLn().sendPayment(invoice))
  }
}

export type CreateInvoiceParams = {
  sats: number
  description?: string
}

export const createInvoice = async ({
  sats,
  description = "Receive via lightning",
}: CreateInvoiceParams) => {
  const $session = session.get()

  if (!$session?.wallet) {
    throw new Error("No wallet is connected")
  }

  const satAmount = Math.floor(sats)

  if (!Number.isFinite(satAmount) || satAmount <= 0) {
    throw new Error("Invalid satoshi amount")
  }

  if ($session.wallet.type === "nwc") {
    const createdInvoice = await getNwcClient().makeInvoice({
      amount: satAmount * 1000,
      description,
    })

    if (!createdInvoice.invoice) {
      throw new Error("NWC wallet failed to return an invoice")
    }

    return createdInvoice.invoice
  }

  if ($session.wallet.type === "webln") {
    const webLn = getWebLn()

    if (!webLn) {
      throw new Error("WebLN not available")
    }

    await webLn.enable()

    const response = await webLn.makeInvoice({
      amount: satAmount,
      defaultMemo: description,
    })

    const paymentRequest =
      typeof response === "string" ? response : response?.paymentRequest || response?.pr || ""

    if (!paymentRequest) {
      throw new Error("Invalid payment request returned from WebLN")
    }

    return paymentRequest
  }

  throw new Error("Unsupported wallet type")
}

// File upload

export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http"))

export const fetchHasBlossomSupport = async (url: string) => {
  const relay = getRelay(url)

  if (relay?.supported_nips?.map(String).includes("BUD-02")) {
    return true
  }

  const server = normalizeBlossomUrl(url)
  const $signer = signer.get() || Nip01Signer.ephemeral()
  const headers: Record<string, string> = {
    "X-Content-Type": "text/plain",
    "X-Content-Length": "1",
    "X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac",
  }

  try {
    const authEvent = await $signer.sign(makeBlossomAuthEvent({action: "upload", server}))
    const res = await canUploadBlob(server, {authEvent, headers})

    return res.status === 200
  } catch (e) {
    if (!String(e).match(/Failed to fetch|NetworkError/)) {
      console.error(e)
    }
  }

  return false
}

export const hasBlossomSupport = simpleCache(([url]: [string]) => fetchHasBlossomSupport(url))

export type GetBlossomServerOptions = {
  url?: string
}

export const getBlossomServer = async (options: GetBlossomServerOptions = {}) => {
  if (options.url) {
    if (await hasBlossomSupport(options.url)) {
      return normalizeBlossomUrl(options.url)
    }
  }

  const userUrls = getTagValues("server", getListTags(get(userBlossomServerList)))

  for (const url of userUrls) {
    return normalizeBlossomUrl(url)
  }

  return first(DEFAULT_BLOSSOM_SERVERS)!
}

export type UploadFileOptions = {
  url?: string
  encrypt?: boolean
  maxWidth?: number
  maxHeight?: number
}

export type UploadFileResult = {
  error?: string
  result?: UploadTask
}

export const uploadFile = async (file: File, options: UploadFileOptions = {}) => {
  try {
    const {name, type} = file

    if (!type.match("image/(webp|gif|svg)")) {
      file = await compressFile(file, options)
    }

    const tags: string[][] = []

    if (options.encrypt) {
      const {ciphertext, key, nonce, algorithm} = await encryptFile(file)

      tags.push(
        ["decryption-key", key],
        ["decryption-nonce", nonce],
        ["encryption-algorithm", algorithm],
      )

      file = new File([new Uint8Array(ciphertext)], name, {
        type: "application/octet-stream",
      })
    }

    const ext = "." + type.split("/")[1]
    const server = await getBlossomServer(options)
    const hashes = [await sha256(await file.arrayBuffer())]
    const $signer = signer.get() || Nip01Signer.ephemeral()
    const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes})
    const authEvent = await $signer.sign(authTemplate)
    const res = await uploadBlob(server, file, {authEvent})
    const text = await res.text()

    let task
    try {
      task = parseJson(text)
    } catch (e) {
      return {error: text}
    }

    if (!task?.uploaded) {
      return {error: text || `Failed to upload file (HTTP ${res.status})`}
    }

    // Always append correct file extension if we encrypted the file, or if it's missing
    let url = task.url
    if (options.encrypt) {
      url = url.replace(/\.\w+$/, "") + ext
    } else if (new URL(url).pathname.split(".").length === 1) {
      url += ext
    }

    const result = {...task, tags, url}

    return {result}
  } catch (e: any) {
    console.error("Error caught when uploading file:", e)

    return {error: e.toString()}
  }
}

// Update Profile

export const initProfile = (profile: Profile) => {
  const template = createProfile(profile)

  // Start out protected by default
  template.tags.push(PROTECTED)

  const event = makeEvent(PROFILE, template)

  // Don't publish anywhere yet, wait until they join a space
  return publishThunk({event, relays: []})
}

export const updateProfile = ({
  profile,
  shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || []),
}: {
  profile: Profile
  shouldBroadcast?: boolean
}) => {
  const router = Router.get()
  const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
  const scenarios = [router.FromRelays(get(userSpaceUrls))]

  if (shouldBroadcast) {
    scenarios.push(router.FromUser(), router.Index())
    template.tags = template.tags.filter(nthNe(0, "-"))
  } else {
    template.tags = uniqTags([...template.tags, PROTECTED])
  }

  const event = makeEvent(template.kind, template)
  const relays = router.merge(scenarios).getUrls()

  return publishThunk({event, relays})
}

// Admin actions

export const addSpaceMembers = async (
  url: string,
  pubkeys: string[],
): Promise<string | undefined> => {
  const spaceMembers = get(deriveSpaceMembers(url))
  const results = await Promise.all(
    pubkeys
      .filter(pubkey => !spaceMembers.includes(pubkey))
      .map(pubkey =>
        manageRelay(url, {
          method: ManagementMethod.AllowPubkey,
          params: [pubkey],
        }),
      ),
  )

  for (const {error} of results) {
    if (error) {
      return error
    }
  }
}

export const addRoomMembers = async (
  url: string,
  room: PublishedRoomMeta,
  pubkeys: string[],
): Promise<string | undefined> => {
  const error = await addSpaceMembers(url, pubkeys)

  if (error) {
    return error
  }

  const errors = await Promise.all(
    pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, room, pubkey))),
  )

  for (const error of errors) {
    if (error) {
      return error
    }
  }
}
