const uuidv4 = require("uuid").v4
const fs = require('../libs/fsExtra')
const Path = require('path')
const { version } = require('../../package.json')
const Logger = require('../Logger')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediaTypes/Video')
const Music = require('./mediaTypes/Music')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../utils/index')
const { filePathToPOSIX } = require('../utils/fileUtils')

class LibraryItem {
  constructor(libraryItem = null) {
    this.id = null
    this.ino = null // Inode

    this.libraryId = null
    this.folderId = null

    this.path = null
    this.relPath = null
    this.isFile = false
    this.mtimeMs = null
    this.ctimeMs = null
    this.birthtimeMs = null
    this.addedAt = null
    this.updatedAt = null
    this.lastScan = null
    this.scanVersion = null

    // Was scanned and no longer exists
    this.isMissing = false
    // Was scanned and no longer has media files
    this.isInvalid = false

    this.mediaType = null
    this.media = null

    this.libraryFiles = []

    if (libraryItem) {
      this.construct(libraryItem)
    }

    // Temporary attributes
    this.isSavingMetadata = false
  }

  construct(libraryItem) {
    this.id = libraryItem.id
    this.ino = libraryItem.ino || null
    this.libraryId = libraryItem.libraryId
    this.folderId = libraryItem.folderId
    this.path = libraryItem.path
    this.relPath = libraryItem.relPath
    this.isFile = !!libraryItem.isFile
    this.mtimeMs = libraryItem.mtimeMs || 0
    this.ctimeMs = libraryItem.ctimeMs || 0
    this.birthtimeMs = libraryItem.birthtimeMs || 0
    this.addedAt = libraryItem.addedAt
    this.updatedAt = libraryItem.updatedAt || this.addedAt
    this.lastScan = libraryItem.lastScan || null
    this.scanVersion = libraryItem.scanVersion || null

    this.isMissing = !!libraryItem.isMissing
    this.isInvalid = !!libraryItem.isInvalid

    this.mediaType = libraryItem.mediaType
    if (this.mediaType === 'book') {
      this.media = new Book(libraryItem.media)
    } else if (this.mediaType === 'podcast') {
      this.media = new Podcast(libraryItem.media)
    } else if (this.mediaType === 'video') {
      this.media = new Video(libraryItem.media)
    } else if (this.mediaType === 'music') {
      this.media = new Music(libraryItem.media)
    }
    this.media.libraryItemId = this.id

    this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))

    // Migration for v2.2.23 to set ebook library files as supplementary
    if (this.isBook && this.media.ebookFile) {
      for (const libraryFile of this.libraryFiles) {
        if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) {
          libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino
        }
      }
    }

  }

  toJSON() {
    return {
      id: this.id,
      ino: this.ino,
      libraryId: this.libraryId,
      folderId: this.folderId,
      path: this.path,
      relPath: this.relPath,
      isFile: this.isFile,
      mtimeMs: this.mtimeMs,
      ctimeMs: this.ctimeMs,
      birthtimeMs: this.birthtimeMs,
      addedAt: this.addedAt,
      updatedAt: this.updatedAt,
      lastScan: this.lastScan,
      scanVersion: this.scanVersion,
      isMissing: !!this.isMissing,
      isInvalid: !!this.isInvalid,
      mediaType: this.mediaType,
      media: this.media.toJSON(),
      libraryFiles: this.libraryFiles.map(f => f.toJSON())
    }
  }

  toJSONMinified() {
    return {
      id: this.id,
      ino: this.ino,
      libraryId: this.libraryId,
      folderId: this.folderId,
      path: this.path,
      relPath: this.relPath,
      isFile: this.isFile,
      mtimeMs: this.mtimeMs,
      ctimeMs: this.ctimeMs,
      birthtimeMs: this.birthtimeMs,
      addedAt: this.addedAt,
      updatedAt: this.updatedAt,
      isMissing: !!this.isMissing,
      isInvalid: !!this.isInvalid,
      mediaType: this.mediaType,
      media: this.media.toJSONMinified(),
      numFiles: this.libraryFiles.length,
      size: this.size
    }
  }

  // Adds additional helpful fields like media duration, tracks, etc.
  toJSONExpanded() {
    return {
      id: this.id,
      ino: this.ino,
      libraryId: this.libraryId,
      folderId: this.folderId,
      path: this.path,
      relPath: this.relPath,
      isFile: this.isFile,
      mtimeMs: this.mtimeMs,
      ctimeMs: this.ctimeMs,
      birthtimeMs: this.birthtimeMs,
      addedAt: this.addedAt,
      updatedAt: this.updatedAt,
      lastScan: this.lastScan,
      scanVersion: this.scanVersion,
      isMissing: !!this.isMissing,
      isInvalid: !!this.isInvalid,
      mediaType: this.mediaType,
      media: this.media.toJSONExpanded(),
      libraryFiles: this.libraryFiles.map(f => f.toJSON()),
      size: this.size
    }
  }

  get isPodcast() { return this.mediaType === 'podcast' }
  get isBook() { return this.mediaType === 'book' }
  get isMusic() { return this.mediaType === 'music' }
  get size() {
    let total = 0
    this.libraryFiles.forEach((lf) => total += lf.metadata.size)
    return total
  }
  get audioFileTotalSize() {
    let total = 0
    this.libraryFiles.filter(lf => lf.fileType == 'audio').forEach((lf) => total += lf.metadata.size)
    return total
  }
  get hasAudioFiles() {
    return this.libraryFiles.some(lf => lf.fileType === 'audio')
  }
  get hasMediaEntities() {
    return this.media.hasMediaEntities
  }
  get hasIssues() {
    if (this.isMissing || this.isInvalid) return true
    return this.media.hasIssues
  }

  // Data comes from scandir library item data
  setData(libraryMediaType, payload) {
    this.id = uuidv4()
    this.mediaType = libraryMediaType
    if (libraryMediaType === 'video') {
      this.media = new Video()
    } else if (libraryMediaType === 'podcast') {
      this.media = new Podcast()
    } else if (libraryMediaType === 'book') {
      this.media = new Book()
    } else if (libraryMediaType === 'music') {
      this.media = new Music()
    }
    this.media.id = uuidv4()
    this.media.libraryItemId = this.id

    for (const key in payload) {
      if (key === 'libraryFiles') {
        this.libraryFiles = payload.libraryFiles.map(lf => lf.clone())

        // Set cover image
        const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
        const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
        if (coverMatch) {
          this.media.coverPath = coverMatch.metadata.path
        } else if (imageFiles.length) {
          this.media.coverPath = imageFiles[0].metadata.path
        }

      } else if (this[key] !== undefined && key !== 'media') {
        this[key] = payload[key]
      }
    }

    if (payload.media) {
      this.media.setData(payload.media)
    }

    this.addedAt = Date.now()
    this.updatedAt = Date.now()
  }

  update(payload) {
    const json = this.toJSON()
    let hasUpdates = false
    for (const key in json) {
      if (payload[key] !== undefined) {
        if (key === 'media') {
          if (this.media.update(payload[key])) {
            hasUpdates = true
          }
        } else if (!areEquivalent(payload[key], json[key])) {
          this[key] = copyValue(payload[key])
          hasUpdates = true
        }
      }
    }
    if (hasUpdates) {
      this.updatedAt = Date.now()
    }
    return hasUpdates
  }

  updateMediaCover(coverPath) {
    this.media.updateCover(coverPath)
    this.updatedAt = Date.now()
    return true
  }

  setMissing() {
    this.isMissing = true
    this.updatedAt = Date.now()
  }

  setInvalid() {
    this.isInvalid = true
    this.updatedAt = Date.now()
  }

  setLastScan() {
    this.lastScan = Date.now()
    this.updatedAt = Date.now()
    this.scanVersion = version
  }

  // Returns null if file not found, true if file was updated, false if up to date
  //  updates existing LibraryFile, AudioFile, EBookFile's
  checkFileFound(fileFound) {
    let hasUpdated = false

    let existingFile = this.libraryFiles.find(lf => lf.ino === fileFound.ino)
    let mediaFile = null
    if (!existingFile) {
      existingFile = this.libraryFiles.find(lf => lf.metadata.path === fileFound.metadata.path)
      if (existingFile) {
        // Update media file ino
        mediaFile = this.media.findFileWithInode(existingFile.ino)
        if (mediaFile) {
          mediaFile.ino = fileFound.ino
        }

        // file inode was updated
        existingFile.ino = fileFound.ino
        hasUpdated = true
      } else {
        // file not found
        return null
      }
    } else {
      mediaFile = this.media.findFileWithInode(existingFile.ino)
    }

    if (existingFile.metadata.path !== fileFound.metadata.path) {
      existingFile.metadata.path = fileFound.metadata.path
      existingFile.metadata.relPath = fileFound.metadata.relPath
      if (mediaFile) {
        mediaFile.metadata.path = fileFound.metadata.path
        mediaFile.metadata.relPath = fileFound.metadata.relPath
      }
      hasUpdated = true
    }

    // FileMetadata keys
    ['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'].forEach((key) => {
      if (existingFile.metadata[key] !== fileFound.metadata[key]) {
        // Add modified flag on file data object if exists and was changed
        if (key === 'mtimeMs' && existingFile.metadata[key]) {
          fileFound.metadata.wasModified = true
        }

        existingFile.metadata[key] = fileFound.metadata[key]
        if (mediaFile) {
          if (key === 'mtimeMs') mediaFile.metadata.wasModified = true
          mediaFile.metadata[key] = fileFound.metadata[key]
        }
        hasUpdated = true
      }
    })

    return hasUpdated
  }

  // Data pulled from scandir during a scan, check it with current data
  checkScanData(dataFound) {
    let hasUpdated = false

    if (this.isMissing) {
      // Item no longer missing
      this.isMissing = false
      hasUpdated = true
    }

    if (dataFound.isFile !== this.isFile && dataFound.isFile !== undefined) {
      Logger.info(`[LibraryItem] Check scan item isFile toggled from ${this.isFile} => ${dataFound.isFile}`)
      this.isFile = dataFound.isFile
      hasUpdated = true
    }

    if (dataFound.ino !== this.ino) {
      Logger.warn(`[LibraryItem] Check scan item changed inode "${this.ino}" -> "${dataFound.ino}"`)
      this.ino = dataFound.ino
      hasUpdated = true
    }

    if (dataFound.folderId !== this.folderId) {
      Logger.warn(`[LibraryItem] Check scan item changed folder ${this.folderId} -> ${dataFound.folderId}`)
      this.folderId = dataFound.folderId
      hasUpdated = true
    }

    if (dataFound.path !== this.path) {
      Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}" (inode ${this.ino})`)
      this.path = dataFound.path
      this.relPath = dataFound.relPath
      hasUpdated = true
    }

    ['mtimeMs', 'ctimeMs', 'birthtimeMs'].forEach((key) => {
      if (dataFound[key] != this[key]) {
        this[key] = dataFound[key] || 0
        hasUpdated = true
      }
    })

    const newLibraryFiles = []
    const existingLibraryFiles = []

    dataFound.libraryFiles.forEach((lf) => {
      const fileFoundCheck = this.checkFileFound(lf, true)
      if (fileFoundCheck === null) {
        newLibraryFiles.push(lf)
      } else if (fileFoundCheck && lf.metadata.format !== 'abs' && lf.metadata.filename !== 'metadata.json') { // Ignore abs file updates
        hasUpdated = true
        existingLibraryFiles.push(lf)
      } else {
        existingLibraryFiles.push(lf)
      }
    })

    const filesRemoved = []

    // Remove files not found (inodes will all be up to date at this point)
    this.libraryFiles = this.libraryFiles.filter(lf => {
      if (!dataFound.libraryFiles.find(_lf => _lf.ino === lf.ino)) {
        // Check if removing cover path
        if (lf.metadata.path === this.media.coverPath) {
          Logger.debug(`[LibraryItem] "${this.media.metadata.title}" check scan cover removed`)
          this.media.updateCover('')
        }
        filesRemoved.push(lf.toJSON())
        this.media.removeFileWithInode(lf.ino)
        return false
      }
      return true
    })
    if (filesRemoved.length) {
      if (this.media.mediaType === 'book') {
        this.media.checkUpdateMissingTracks()
      }
      hasUpdated = true
    }

    // Add library files to library item
    if (newLibraryFiles.length) {
      newLibraryFiles.forEach((lf) => this.libraryFiles.push(lf.clone()))
      hasUpdated = true
    }

    // Check if invalid
    this.isInvalid = !this.media.hasMediaEntities

    // If cover path is in item folder, make sure libraryFile exists for it
    if (this.media.coverPath && this.media.coverPath.startsWith(this.path)) {
      const lf = this.libraryFiles.find(lf => lf.metadata.path === this.media.coverPath)
      if (!lf) {
        Logger.warn(`[LibraryItem] Invalid cover path - library file dne "${this.media.coverPath}"`)
        this.media.updateCover('')
        hasUpdated = true
      }
    }

    if (hasUpdated) {
      this.setLastScan()
    }

    return {
      updated: hasUpdated,
      newLibraryFiles,
      filesRemoved,
      existingLibraryFiles // Existing file data may get re-scanned if forceRescan is set
    }
  }

  // Set metadata from files
  async syncFiles(preferOpfMetadata, librarySettings) {
    let hasUpdated = false

    if (this.isBook) {
      // Add/update ebook files (ebooks that were removed are removed in checkScanData)
      if (librarySettings.audiobooksOnly) {
        hasUpdated = this.media.ebookFile
        if (hasUpdated) {
          // If library was set to audiobooks only then set primary ebook as supplementary
          Logger.info(`[LibraryItem] Library is audiobooks only so setting ebook "${this.media.ebookFile.metadata.filename}" as supplementary`)
        }
        this.setPrimaryEbook(null)
      } else if (this.media.ebookFile) {
        const matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === this.media.ebookFile.ino)
        if (matchingLibraryFile && this.media.ebookFile.updateFromLibraryFile(matchingLibraryFile)) {
          hasUpdated = true
        }
        // Set any other ebook files as supplementary
        const suppEbookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary && this.media.ebookFile.ino !== lf.ino)
        if (suppEbookLibraryFiles.length) {
          for (const libraryFile of suppEbookLibraryFiles) {
            libraryFile.isSupplementary = true
          }
          hasUpdated = true
        }
      } else {
        const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary)

        // Prefer epub ebook then fallback to first other ebook file
        const ebookLibraryFile = ebookLibraryFiles.find(lf => lf.metadata.format === 'epub') || ebookLibraryFiles[0]
        if (ebookLibraryFile) {
          this.setPrimaryEbook(ebookLibraryFile)
          hasUpdated = true
        }
      }
    }

    // Set cover image if not set
    const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
    if (imageFiles.length && !this.media.coverPath) {
      // attempt to find a file called cover.<ext> otherwise just fall back to the first image found
      const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
      if (coverMatch) {
        this.media.coverPath = coverMatch.metadata.path
      } else {
        this.media.coverPath = imageFiles[0].metadata.path
      }
      Logger.info('[LibraryItem] Set media cover path', this.media.coverPath)
      hasUpdated = true
    }

    // Parse metadata files
    const textMetadataFiles = this.libraryFiles.filter(lf => lf.fileType === 'metadata' || lf.fileType === 'text')
    if (textMetadataFiles.length) {
      if (await this.media.syncMetadataFiles(textMetadataFiles, preferOpfMetadata)) {
        hasUpdated = true
      }
    }

    if (hasUpdated) {
      this.updatedAt = Date.now()
    }
    return hasUpdated
  }

  searchQuery(query) {
    query = cleanStringForSearch(query)
    return this.media.searchQuery(query)
  }

  getDirectPlayTracklist(episodeId) {
    return this.media.getDirectPlayTracklist(episodeId)
  }

  // Saves metadata.abs file
  async saveMetadata() {
    if (this.mediaType === 'video' || this.mediaType === 'music') return

    if (this.isSavingMetadata) return
    this.isSavingMetadata = true

    let metadataPath = Path.join(global.MetadataPath, 'items', this.id)
    if (global.ServerSettings.storeMetadataWithItem && !this.isFile) {
      metadataPath = this.path
    } else {
      // Make sure metadata book dir exists
      await fs.ensureDir(metadataPath)
    }

    const metadataFileFormat = global.ServerSettings.metadataFileFormat
    const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`)
    if (metadataFileFormat === 'json') {
      // Remove metadata.abs if it exists
      if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) {
        Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`)
        await fs.remove(Path.join(metadataPath, `metadata.abs`))
        this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`)))
      }

      return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
        this.isSavingMetadata = false
        // Add metadata.json to libraryFiles array if it is new
        if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
          const newLibraryFile = new LibraryFile()
          await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
          this.libraryFiles.push(newLibraryFile)
        }

        return true
      }).catch((error) => {
        this.isSavingMetadata = false
        Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
        return false
      })
    } else {
      // Remove metadata.json if it exists
      if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) {
        Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`)
        await fs.remove(Path.join(metadataPath, `metadata.json`))
        this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`)))
      }

      return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => {
        this.isSavingMetadata = false
        if (!success) Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`)
        else {
          // Add metadata.abs to libraryFiles array if it is new
          if (global.ServerSettings.storeMetadataWithItem && !this.libraryFiles.some(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))) {
            const newLibraryFile = new LibraryFile()
            await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
            this.libraryFiles.push(newLibraryFile)
          }

          Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
        }
        return success
      })
    }
  }

  removeLibraryFile(ino) {
    if (!ino) return false
    const libraryFile = this.libraryFiles.find(lf => lf.ino === ino)
    if (libraryFile) {
      this.libraryFiles = this.libraryFiles.filter(lf => lf.ino !== ino)
      this.updatedAt = Date.now()
      return true
    }
    return false
  }

  /**
   * Set the EBookFile from a LibraryFile
   * If null then ebookFile will be removed from the book
   * all ebook library files that are not primary are marked as supplementary
   * 
   * @param {LibraryFile} [libraryFile] 
   */
  setPrimaryEbook(ebookLibraryFile = null) {
    const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile)
    for (const libraryFile of ebookLibraryFiles) {
      libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino
    }
    this.media.setEbookFile(ebookLibraryFile)
  }
}
module.exports = LibraryItem