import * as CFI from './epubcfi.js'
import {SectionProgress, TOCProgress} from './progress.js'
import {Overlayer} from './overlayer.js'
import {textWalker} from './text-walker.js'
import {makeStreamingLoader} from './streaming-loader.js'

const SEARCH_PREFIX = 'foliate-search:'

const isZip = async file => {
  const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
  return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
}

const isPDF = async file => {
  const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer())
  return arr[0] === 0x25
    && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46
    && arr[4] === 0x2d
}

const isCBZ = ({name, type}) =>
  type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz')

const isFB2 = async file => {
  const {name, type} = file
  console.log('[isFB2] name:', name, 'type:', type)
  if (type === 'application/x-fictionbook+xml' || (name && name.endsWith('.fb2'))) {
    return true
  }
  // Check file content for FB2 XML signature
  try {
    const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer())
    // '<?xml' in UTF-8: 60 63 120 109 108
    const isXml = arr[0] === 0x3c && arr[1] === 0x3f && arr[2] === 0x78 && arr[3] === 0x6d && arr[4] === 0x6c
    if (isXml) {
      console.log('[isFB2] Detected FB2 XML signature in file content')
      return true
    }
  } catch (e) {
    console.error('[isFB2] Error reading file content:', e)
  }
  return false
}

const isFBZ = ({name, type}) =>
  type === 'application/x-zip-compressed-fb2'
  || name.endsWith('.fb2.zip') || name.endsWith('.fbz')

const makeZipLoader = async file => {
  const {configure, ZipReader, BlobReader, TextWriter, BlobWriter} =
    await import('./vendor/zip.js')
  configure({useWebWorkers: false})
  const reader = new ZipReader(new BlobReader(file))
  const entries = await reader.getEntries()
  const map = new Map(entries.map(entry => [entry.filename, entry]))
  const load = f => (name, ...args) =>
    map.has(name) ? f(map.get(name), ...args) : null
  const loadText = load(entry => entry.getData(new TextWriter()))
  const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)))
  const getSize = name => map.get(name)?.uncompressedSize ?? 0
  return {entries, loadText, loadBlob, getSize}
}

const getFileEntries = async entry => entry.isFile ? entry
  : (await Promise.all(Array.from(
    await new Promise((resolve, reject) => entry.createReader()
      .readEntries(entries => resolve(entries), error => reject(error))),
    getFileEntries))).flat()

const makeDirectoryLoader = async entry => {
  const entries = await getFileEntries(entry)
  const files = await Promise.all(
    entries.map(entry => new Promise((resolve, reject) =>
      entry.file(file => resolve([file, entry.fullPath]),
        error => reject(error)))))
  const map = new Map(files.map(([file, path]) =>
    [path.replace(entry.fullPath + '/', ''), file]))
  const decoder = new TextDecoder()
  const decode = x => x ? decoder.decode(x) : null
  const getBuffer = name => map.get(name)?.arrayBuffer() ?? null
  const loadText = async name => decode(await getBuffer(name))
  const loadBlob = name => map.get(name)
  const getSize = name => map.get(name)?.size ?? 0
  return {loadText, loadBlob, getSize}
}

export class ResponseError extends Error {
}

export class NotFoundError extends Error {
}

export class UnsupportedTypeError extends Error {
}

const fetchFile = async url => {
  const res = await fetch(url)
  if (!res.ok) throw new ResponseError(
    `${res.status} ${res.statusText}`, {cause: res})
  return new File([await res.blob()], new URL(res.url).pathname)
}

export const makeBook = async file => {
  if (typeof file === 'string') file = await fetchFile(file)
  let book
  if (file.isDirectory) {
    const loader = await makeDirectoryLoader(file)
    const {EPUB} = await import('./epub.js')
    book = await new EPUB(loader).init()
  } else if (!file.size) throw new NotFoundError('File not found')
  else if (await isZip(file)) {
    const loader = await makeZipLoader(file)
    if (isCBZ(file)) {
      const {makeComicBook} = await import('./comic-book.js')
      book = makeComicBook(loader, file)
    } else if (isFBZ(file)) {
      const {makeFB2} = await import('./fb2.js')
      const {entries} = loader
      const entry = entries.find(entry => entry.filename.endsWith('.fb2'))
      const blob = await loader.loadBlob((entry ?? entries[0]).filename)
      book = await makeFB2(blob)
    } else {
      const {EPUB} = await import('./epub.js')
      book = await new EPUB(loader).init()
    }
  } else if (await isPDF(file)) {
    const {makePDF} = await import('./pdf.js')
    book = await makePDF(file)
  } else {
    const {isMOBI, MOBI} = await import('./mobi.js')
    if (await isMOBI(file)) {
      const fflate = await import('./vendor/fflate.js')
      book = await new MOBI({unzlib: fflate.unzlibSync}).open(file)
    } else if (isFB2(file)) {
      const {makeFB2} = await import('./fb2.js')
      book = await makeFB2(file)
    }
  }
  if (!book) throw new UnsupportedTypeError('File type not supported')
  return book
}

/**
 * Create an EPUB book using streaming loader instead of ZIP extraction.
 * This avoids loading the entire EPUB into memory.
 *
 * @param {number} bookId - Book ID for API requests
 * @param {string} baseUrl - API base URL for EPUB endpoints
 * @param {Object} bookInfo - Pre-fetched metadata from /api/v1/epub/{bookId}/info
 * @param {string} [authToken] - Optional authentication token
 * @param {string} [bookType] - Optional book type for alternative format (e.g., 'EPUB')
 * @returns {Promise<EPUB>} Initialized EPUB book object
 */
export const makeStreamingBook = async (bookId, baseUrl, bookInfo, authToken = null, bookType = null) => {
  const loader = makeStreamingLoader(bookId, baseUrl, bookInfo, authToken, bookType)
  const {EPUB} = await import('./epub.js')
  return new EPUB(loader).init()
}

class CursorAutohider {
  #timeout
  #el
  #check
  #state

  constructor(el, check, state = {}) {
    this.#el = el
    this.#check = check
    this.#state = state
    if (this.#state.hidden) this.hide()
    this.#el.addEventListener('mousemove', ({screenX, screenY}) => {
      // check if it actually moved
      if (screenX === this.#state.x && screenY === this.#state.y) return
      this.#state.x = screenX, this.#state.y = screenY
      this.show()
      if (this.#timeout) clearTimeout(this.#timeout)
      if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000)
    }, false)
  }

  cloneFor(el) {
    return new CursorAutohider(el, this.#check, this.#state)
  }

  hide() {
    this.#el.style.cursor = 'none'
    this.#state.hidden = true
  }

  show() {
    this.#el.style.removeProperty('cursor')
    this.#state.hidden = false
  }
}

class History extends EventTarget {
  #arr = []
  #index = -1

  pushState(x) {
    const last = this.#arr[this.#index]
    if (last === x || last?.fraction && last.fraction === x.fraction) return
    this.#arr[++this.#index] = x
    this.#arr.length = this.#index + 1
    this.dispatchEvent(new Event('index-change'))
  }

  replaceState(x) {
    const index = this.#index
    this.#arr[index] = x
  }

  back() {
    const index = this.#index
    if (index <= 0) return
    const detail = {state: this.#arr[index - 1]}
    this.#index = index - 1
    this.dispatchEvent(new CustomEvent('popstate', {detail}))
    this.dispatchEvent(new Event('index-change'))
  }

  forward() {
    const index = this.#index
    if (index >= this.#arr.length - 1) return
    const detail = {state: this.#arr[index + 1]}
    this.#index = index + 1
    this.dispatchEvent(new CustomEvent('popstate', {detail}))
    this.dispatchEvent(new Event('index-change'))
  }

  get canGoBack() {
    return this.#index > 0
  }

  get canGoForward() {
    return this.#index < this.#arr.length - 1
  }

  clear() {
    this.#arr = []
    this.#index = -1
  }
}

const languageInfo = lang => {
  if (!lang) return {}
  try {
    const canonical = Intl.getCanonicalLocales(lang)[0]
    const locale = new Intl.Locale(canonical)
    const isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
    const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction
    return {canonical, locale, isCJK, direction}
  } catch (e) {
    console.warn(e)
    return {}
  }
}

export class View extends HTMLElement {
  #root = this.attachShadow({mode: 'closed'})
  #sectionProgress
  #tocProgress
  #pageProgress
  #searchResults = new Map()
  #cursorAutohider = new CursorAutohider(this, () =>
    this.hasAttribute('autohide-cursor'))
  isFixedLayout = false
  lastLocation
  history = new History()

  constructor() {
    super()
    this.history.addEventListener('popstate', ({detail}) => {
      const resolved = this.resolveNavigation(detail.state)
      this.renderer.goTo(resolved)
    })
  }

  async open(book) {
    if (typeof book === 'string'
      || typeof book.arrayBuffer === 'function'
      || book.isDirectory) book = await makeBook(book)
    this.book = book
    this.language = languageInfo(book.metadata?.language)

    if (book.splitTOCHref && book.getTOCFragment) {
      const ids = book.sections.map(s => s.id)
      this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600)
      const splitHref = book.splitTOCHref.bind(book)
      const getFragment = book.getTOCFragment.bind(book)
      this.#tocProgress = new TOCProgress()
      await this.#tocProgress.init({
        toc: book.toc ?? [], ids, splitHref, getFragment
      })
      this.#pageProgress = new TOCProgress()
      await this.#pageProgress.init({
        toc: book.pageList ?? [], ids, splitHref, getFragment
      })
    }

    this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
    if (this.isFixedLayout) {
      await import('./fixed-layout.js')
      this.renderer = document.createElement('foliate-fxl')
    } else {
      await import('./paginator.js')
      this.renderer = document.createElement('foliate-paginator')
    }
    this.renderer.setAttribute('exportparts', 'head,foot,filter')
    this.renderer.addEventListener('load', e => this.#onLoad(e.detail))
    this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail))
    this.renderer.addEventListener('create-overlayer', e =>
      e.detail.attach(this.#createOverlayer(e.detail)))
    this.renderer.open(book)
    this.#root.append(this.renderer)

    if (book.sections.some(section => section.mediaOverlay)) {
      const activeClass = book.media.activeClass
      const playbackActiveClass = book.media.playbackActiveClass
      this.mediaOverlay = book.getMediaOverlay()
      let lastActive
      this.mediaOverlay.addEventListener('highlight', e => {
        const resolved = this.resolveNavigation(e.detail.text)
        this.renderer.goTo(resolved)
          .then(() => {
            const {doc} = this.renderer.getContents()
              .find(x => x.index = resolved.index)
            const el = resolved.anchor(doc)
            el.classList.add(activeClass)
            if (playbackActiveClass) el.ownerDocument
              .documentElement.classList.add(playbackActiveClass)
            lastActive = new WeakRef(el)
          })
      })
      this.mediaOverlay.addEventListener('unhighlight', () => {
        const el = lastActive?.deref()
        if (el) {
          el.classList.remove(activeClass)
          if (playbackActiveClass) el.ownerDocument
            .documentElement.classList.remove(playbackActiveClass)
        }
      })
    }
  }

  close() {
    this.renderer?.destroy()
    this.renderer?.remove()
    this.#sectionProgress = null
    this.#tocProgress = null
    this.#pageProgress = null
    this.#searchResults = new Map()
    this.lastLocation = null
    this.history.clear()
    this.tts = null
    this.mediaOverlay = null
  }

  goToTextStart() {
    return this.goTo(this.book.landmarks
      ?.find(m => m.type.includes('bodymatter') || m.type.includes('text'))
      ?.href ?? this.book.sections.findIndex(s => s.linear !== 'no'))
  }

  async init({lastLocation, showTextStart}) {
    const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null
    if (resolved) {
      await this.renderer.goTo(resolved)
      this.history.pushState(lastLocation)
    } else if (showTextStart) await this.goToTextStart()
    else {
      this.history.pushState(0)
      await this.next()
    }
  }

  #emit(name, detail, cancelable) {
    return this.dispatchEvent(new CustomEvent(name, {detail, cancelable}))
  }

  #onRelocate({reason, range, index, fraction, size}) {
    const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {}
    const tocItem = this.#tocProgress?.getProgress(index, range)
    const pageItem = this.#pageProgress?.getProgress(index, range)
    const cfi = this.getCFI(index, range)
    this.lastLocation = {...progress, tocItem, pageItem, cfi, range}
    if (reason === 'snap' || reason === 'page' || reason === 'scroll')
      this.history.replaceState(cfi)
    this.#emit('relocate', this.lastLocation)
  }

  #onLoad({doc, index}) {
    // set language and dir if not already set
    doc.documentElement.lang ||= this.language.canonical ?? ''
    if (!this.language.isCJK)
      doc.documentElement.dir ||= this.language.direction ?? ''

    this.#handleLinks(doc, index)
    this.#cursorAutohider.cloneFor(doc.documentElement)

    this.#emit('load', {doc, index})
  }

  #handleLinks(doc, index) {
    const {book} = this
    const section = book.sections[index]
    doc.addEventListener('click', e => {
      const a = e.target.closest('a[href]')
      if (!a) return
      e.preventDefault()
      const href_ = a.getAttribute('href')
      const href = section?.resolveHref?.(href_) ?? href_
      if (book?.isExternal?.(href))
        Promise.resolve(this.#emit('external-link', {a, href}, true))
          .then(x => x ? globalThis.open(href, '_blank') : null)
          .catch(e => console.error(e))
      else Promise.resolve(this.#emit('link', {a, href}, true))
        .then(x => x ? this.goTo(href) : null)
        .catch(e => console.error(e))
    })
  }

  async addAnnotation(annotation, remove) {
    const {value} = annotation
    if (value.startsWith(SEARCH_PREFIX)) {
      const cfi = value.replace(SEARCH_PREFIX, '')
      const {index, anchor} = await this.resolveNavigation(cfi)
      const obj = this.#getOverlayer(index)
      if (obj) {
        const {overlayer, doc} = obj
        if (remove) {
          overlayer.remove(value)
          return
        }
        const range = doc ? anchor(doc) : anchor
        overlayer.add(value, range, Overlayer.outline)
      }
      return
    }
    const {index, anchor} = await this.resolveNavigation(value)
    const obj = this.#getOverlayer(index)
    if (obj) {
      const {overlayer, doc} = obj
      overlayer.remove(value)
      if (!remove) {
        const range = doc ? anchor(doc) : anchor
        const draw = (func, opts) => overlayer.add(value, range, func, opts)
        this.#emit('draw-annotation', {draw, annotation, doc, range})
      }
    }
    const label = this.#tocProgress.getProgress(index)?.label ?? ''
    return {index, label}
  }

  deleteAnnotation(annotation) {
    return this.addAnnotation(annotation, true)
  }

  #getOverlayer(index) {
    return this.renderer.getContents()
      .find(x => x.index === index && x.overlayer)
  }

  #createOverlayer({doc, index}) {
    const overlayer = new Overlayer()
    doc.addEventListener('click', e => {
      const [value, range] = overlayer.hitTest(e)
      if (value && !value.startsWith(SEARCH_PREFIX)) {
        this.#emit('show-annotation', {value, index, range})
      }
    }, false)

    const list = this.#searchResults.get(index)
    if (list) for (const item of list) this.addAnnotation(item)

    this.#emit('create-overlay', {index})
    return overlayer
  }

  async showAnnotation(annotation) {
    const {value} = annotation
    const resolved = await this.goTo(value)
    if (resolved) {
      const {index, anchor} = resolved
      const {doc} = this.#getOverlayer(index)
      const range = anchor(doc)
      this.#emit('show-annotation', {value, index, range})
    }
  }

  getCFI(index, range) {
    const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
    if (!range) return baseCFI
    return CFI.joinIndir(baseCFI, CFI.fromRange(range))
  }

  resolveCFI(cfi) {
    if (this.book.resolveCFI)
      return this.book.resolveCFI(cfi)
    else {
      const parts = CFI.parse(cfi)
      const index = CFI.fake.toIndex((parts.parent ?? parts).shift())
      const anchor = doc => CFI.toRange(doc, parts)
      return {index, anchor}
    }
  }

  resolveNavigation(target) {
    try {
      if (typeof target === 'number') return {index: target}
      if (typeof target.fraction === 'number') {
        const [index, anchor] = this.#sectionProgress.getSection(target.fraction)
        return {index, anchor}
      }
      if (CFI.isCFI.test(target)) return this.resolveCFI(target)
      return this.book.resolveHref(target)
    } catch (e) {
      console.error(e)
      console.error(`Could not resolve target ${target}`)
    }
  }

  async goTo(target) {
    const resolved = this.resolveNavigation(target)
    try {
      await this.renderer.goTo(resolved)
      this.history.pushState(target)
      return resolved
    } catch (e) {
      console.error(e)
      console.error(`Could not go to ${target}`)
    }
  }

  async goToFraction(frac) {
    const [index, anchor] = this.#sectionProgress.getSection(frac)
    await this.renderer.goTo({index, anchor})
    this.history.pushState({fraction: frac})
  }

  async select(target) {
    try {
      const obj = await this.resolveNavigation(target)
      await this.renderer.goTo({...obj, select: true})
      this.history.pushState(target)
    } catch (e) {
      console.error(e)
      console.error(`Could not go to ${target}`)
    }
  }

  deselect() {
    for (const {doc} of this.renderer.getContents())
      doc.defaultView.getSelection().removeAllRanges()
  }

  getSectionFractions() {
    return (this.#sectionProgress?.sectionFractions ?? [])
      .map(x => x + Number.EPSILON)
  }

  getProgressOf(index, range) {
    const tocItem = this.#tocProgress?.getProgress(index, range)
    const pageItem = this.#pageProgress?.getProgress(index, range)
    return {tocItem, pageItem}
  }

  async getTOCItemOf(target) {
    try {
      const {index, anchor} = await this.resolveNavigation(target)
      const doc = await this.book.sections[index].createDocument()
      const frag = anchor(doc)
      const isRange = frag instanceof Range
      const range = isRange ? frag : doc.createRange()
      if (!isRange) range.selectNodeContents(frag)
      return this.#tocProgress.getProgress(index, range)
    } catch (e) {
      console.error(e)
      console.error(`Could not get ${target}`)
    }
  }

  async prev(distance) {
    await this.renderer.prev(distance)
  }

  async next(distance) {
    await this.renderer.next(distance)
  }

  goLeft() {
    return this.book.dir === 'rtl' ? this.next() : this.prev()
  }

  goRight() {
    return this.book.dir === 'rtl' ? this.prev() : this.next()
  }

  async* #searchSection(matcher, query, index) {
    const doc = await this.book.sections[index].createDocument()
    for (const {range, excerpt} of matcher(doc, query))
      yield {cfi: this.getCFI(index, range), excerpt}
  }

  async* #searchBook(matcher, query) {
    const {sections} = this.book
    for (const [index, {createDocument}] of sections.entries()) {
      if (!createDocument) continue
      const doc = await createDocument()
      const subitems = Array.from(matcher(doc, query), ({range, excerpt}) =>
        ({cfi: this.getCFI(index, range), excerpt}))
      const progress = (index + 1) / sections.length
      yield {progress}
      if (subitems.length) yield {index, subitems}
    }
  }

  async* search(opts) {
    this.clearSearch()
    const {searchMatcher} = await import('./search.js')
    const {query, index} = opts
    const matcher = searchMatcher(textWalker,
      {defaultLocale: this.language, ...opts})
    const iter = index != null
      ? this.#searchSection(matcher, query, index)
      : this.#searchBook(matcher, query)

    const list = []
    this.#searchResults.set(index, list)

    for await (const result of iter) {
      if (result.subitems) {
        const list = result.subitems
          .map(({cfi}) => ({value: SEARCH_PREFIX + cfi}))
        this.#searchResults.set(result.index, list)
        for (const item of list) this.addAnnotation(item)
        yield {
          label: this.#tocProgress.getProgress(result.index)?.label ?? '',
          subitems: result.subitems,
        }
      } else {
        if (result.cfi) {
          const item = {value: SEARCH_PREFIX + result.cfi}
          list.push(item)
          this.addAnnotation(item)
        }
        yield result
      }
    }
    yield 'done'
  }

  clearSearch() {
    for (const list of this.#searchResults.values())
      for (const item of list) this.deleteAnnotation(item)
    this.#searchResults.clear()
  }

  async initTTS(granularity = 'word', highlight) {
    const doc = this.renderer.getContents()[0].doc
    if (this.tts && this.tts.doc === doc) return
    const {TTS} = await import('./tts.js')
    this.tts = new TTS(doc, textWalker, highlight || (range =>
      this.renderer.scrollToAnchor(range, true)), granularity)
  }

  startMediaOverlay() {
    const {index} = this.renderer.getContents()[0]
    return this.mediaOverlay.start(index)
  }
}

customElements.define('foliate-view', View)

// Export makeStreamingBook to window for use from Angular
window.makeStreamingBook = makeStreamingBook
