import type {CommandProps, Editor} from "@tiptap/core"
import {Extension} from "@tiptap/core"
import {now} from "@welshman/lib"
import type {StampedEvent, SignedEvent} from "@welshman/util"
import type {ImageAttributes, VideoAttributes} from "nostr-editor"
import {readServerConfig, uploadFile} from "nostr-tools/nip96"
import {getToken} from "nostr-tools/nip98"
import type {Node} from "prosemirror-model"
import {Plugin, PluginKey} from "prosemirror-state"
import {writable} from "svelte/store"

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    uploadFile: {
      selectFiles: () => ReturnType
      uploadFiles: () => ReturnType
      getMetaTags: () => string[][]
    }
  }
}

export interface FileUploadOptions {
  allowedMimeTypes: string[]
  expiration: number
  immediateUpload: boolean
  hash: (file: File) => Promise<string>
  sign?: (event: StampedEvent) => Promise<SignedEvent | undefined>
  onDrop: (currentEditor: Editor, file: File, pos: number) => void
  onComplete: (currentEditor: Editor) => void
}

interface UploadTask {
  url?: string
  sha256?: string
  error?: string
}

function bufferToHex(buffer: ArrayBuffer) {
  return Array.from(new Uint8Array(buffer))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("")
}

export const FileUploadExtension = Extension.create<FileUploadOptions>({
  name: "fileUpload",

  addStorage() {
    return {
      loading: writable(false),
      tags: [] as string[][],
    }
  },

  addOptions() {
    return {
      allowedMimeTypes: [
        "image/jpeg",
        "image/png",
        "image/gif",
        "video/mp4",
        "video/mpeg",
        "video/webm",
      ],
      immediateUpload: true,
      expiration: 60000,
      async hash(file: File) {
        return bufferToHex(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))
      },
      onDrop() {},
      onComplete() {},
    }
  },

  addCommands() {
    return {
      selectFiles: () => props => {
        props.tr.setMeta("selectFiles", true)
        return true
      },
      uploadFiles: () => (props: CommandProps) => {
        props.tr.setMeta("uploadFiles", true)
        return true
      },
      getMetaTags: () =>
        ((props: CommandProps) => {
          const tags: string[][] = []
          // make sure the file uploaded is still in the editor content
          props.editor.state.doc.descendants(node => {
            if (!(node.type.name === "image" || node.type.name === "video")) {
              return
            }
            const tag = props.editor.storage.fileUpload.tags.find((t: string[]) =>
              t[1].includes(node.attrs.src),
            )
            if (tag) {
              tags.push(tag)
            }
          })
          return tags
        }) as any,
    }
  },

  addProseMirrorPlugins() {
    const uploader = new Uploader(this.editor, this.options)
    return [
      new Plugin({
        key: new PluginKey("fileUploadPlugin"),
        state: {
          init() {
            return {}
          },
          apply(tr) {
            setTimeout(() => {
              if (tr.getMeta("selectFiles")) {
                uploader.selectFiles()
                tr.setMeta("selectFiles", null)
              } else if (tr.getMeta("uploadFiles")) {
                uploader.uploadFiles()
                tr.setMeta("uploadFiles", null)
              }
            })
            return {}
          },
        },
        props: {
          handleDrop: (_, event) => {
            return uploader.handleDrop(event)
          },
        },
      }),
    ]
  },
})

class Uploader {
  constructor(
    public editor: Editor,
    private options: FileUploadOptions,
  ) {}

  get view() {
    return this.editor.view
  }

  addFile(file: File, pos: number) {
    if (
      !this.options.allowedMimeTypes.some(amt => amt.split("*").every(s => file.type.includes(s)))
    ) {
      return false
    }
    const {tr} = this.view.state
    const [mimetype] = file.type.split("/")
    const node = this.view.state.schema.nodes[mimetype].create({
      file,
      src: URL.createObjectURL(file),
      alt: "",
      uploading: false,
      uploadError: null,
    })
    tr.insert(pos, node)
    this.view.dispatch(tr)

    if (this.options.immediateUpload) {
      this.editor.storage.fileUpload.loading.set(true)
      this.upload(node).then(() => this.editor.storage.fileUpload.loading.set(false))
    }
    this.options.onDrop(this.editor, file, pos)
    return true
  }

  findNodePosition(node: Node) {
    let pos = -1
    this.view.state.doc.descendants((n, p) => {
      if (n === node) {
        pos = p
        return false
      }
    })
    return pos
  }

  findNodes(uploading: boolean) {
    const nodes = [] as [Node, number][]
    this.view.state.doc.descendants((node, pos) => {
      if (!(node.type.name === "image" || node.type.name === "video")) {
        return
      }
      if (node.attrs.sha256) {
        return
      }
      if ((node.attrs.uploading || false) !== uploading) {
        return
      }
      nodes.push([node, pos])
    })
    return nodes
  }

  updateNodeAttributes(nodeRef: Node, attrs: Record<string, unknown>) {
    const {tr} = this.editor.view.state

    const pos = this.findNodePosition(nodeRef)
    if (pos === -1) return

    Object.entries(attrs).forEach(
      ([key, value]) => value !== undefined && tr.setNodeAttribute(pos, key, value),
    )
    this.view.dispatch(tr)
  }

  onUploadDone(nodeRef: Node, response: UploadTask) {
    this.findNodes(true).forEach(([node, pos]) => {
      if (node.attrs.src === nodeRef.attrs.src) {
        this.updateNodeAttributes(node, {
          uploading: false,
          src: response.url,
          sha256: response.sha256,
          uploadError: response.error,
        })
      }
    })
  }

  async upload(node: Node) {
    const {sign, hash, expiration} = this.options

    const {
      file,
      alt,
      uploadType,
      uploadUrl: serverUrl,
    } = node.attrs as ImageAttributes | VideoAttributes

    this.updateNodeAttributes(node, {uploading: true, uploadError: null})

    try {
      if (uploadType === "nip96") {
        const res = (await uploadNIP96({file, alt, sign: sign!, serverUrl}))!

        // add the tags as received from nip-96 to the storage
        this.editor.storage.fileUpload.tags.push(["imeta", ...res.tags!])
        this.onUploadDone(node, res)
      } else {
        const res = await uploadBlossom({file, serverUrl, hash, sign, expiration})
        this.editor.storage.fileUpload.tags.push([
          "imeta",
          `url ${res.url}`,
          `size ${res.size}`,
          `m ${res.type}`,
          `x ${res.sha256}`,
        ])
        this.onUploadDone(node, res)
      }
    } catch (error) {
      const msg = error as string
      this.onUploadDone(node, {error: msg})
      throw new Error(msg as string)
    }
  }

  async uploadFiles() {
    const tasks = this.findNodes(false).map(([node]) => {
      return this.upload(node)
    })
    try {
      this.editor.storage.fileUpload.loading.set(true)
      await Promise.all(tasks)
      this.options.onComplete(this.editor)
    } finally {
      this.editor.storage.fileUpload.loading.set(false)
    }
  }

  selectFiles() {
    const input = document.createElement("input")
    input.type = "file"
    input.multiple = true
    input.accept = this.options.allowedMimeTypes.join(",")
    input.onchange = event => {
      const files = (event.target as HTMLInputElement).files
      if (files) {
        Array.from(files).forEach(file => {
          if (file) {
            const pos = this.view.state.selection.from + 1
            this.addFile(file, pos)
          }
        })
      }
    }
    input.click()
  }

  handleDrop(event: DragEvent) {
    event.preventDefault()

    const pos = this.view.posAtCoords({left: event.clientX, top: event.clientY})?.pos

    if (pos === undefined) return false

    const file = event.dataTransfer?.files?.[0]
    if (file) {
      this.addFile(file, pos)
    }
  }
}

export interface NIP96Options {
  file: File
  alt?: string
  serverUrl: string
  expiration?: number
  sign: (event: StampedEvent) => Promise<SignedEvent | undefined>
}

export async function uploadNIP96(options: NIP96Options) {
  try {
    const server = await readServerConfig(options.serverUrl)
    const authorization = await getToken(server.api_url, "POST", options.sign as any, true)
    const res = await uploadFile(options.file, server.api_url, authorization, {
      alt: options.alt || "",
      expiration: options.expiration?.toString() || "",
      content_type: options.file.type,
    })
    if (res.status === "error") {
      throw new Error(res.message)
    }
    const url = res.nip94_event?.tags.find(x => x[0] === "url")?.[1] || ""
    const sha256 = res.nip94_event?.tags.find(x => x[0] === "x")?.[1] || ""
    return {
      url,
      sha256,
      tags: res.nip94_event?.tags.flatMap(item => item.join(" ")),
    }
  } catch (error) {
    console.warn(error)
  }
}

export interface BlossomOptions {
  file: File
  serverUrl: string
  expiration?: number
  hash?: (file: File) => Promise<string>
  sign?: (event: StampedEvent) => Promise<SignedEvent | undefined>
}

export interface BlossomResponse {
  sha256: string
  size: number
  type: string
  uploaded: number
  url: string
}

export interface BlossomResponseError {
  message: string
}

export async function uploadBlossom(options: BlossomOptions) {
  if (!options.hash) {
    throw new Error("No hash function provided")
  }
  if (!options.sign) {
    throw new Error("No signer provided")
  }
  const created_at = now()
  const hash = await options.hash(options.file)
  const event = await options.sign({
    kind: 24242,
    content: `Upload ${options.file.name}`,
    created_at,
    tags: [
      ["t", "upload"],
      ["x", hash],
      ["size", options.file.size.toString()],
      ["expiration", (created_at + (options.expiration || 60000)).toString()],
    ],
  })
  const data = JSON.stringify(event)
  const base64 = btoa(data)
  const authorization = `Nostr ${base64}`
  const res = await fetch(options.serverUrl + "/upload", {
    method: "PUT",
    body: options.file,
    headers: {
      authorization,
    },
  })
  const json = await res.json()
  if (res.status === 200) {
    return json as BlossomResponse
  }
  throw new Error((json as BlossomResponseError).message)
}
