const Sequelize = require('sequelize')
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')

const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')

const TaskManager = require('../managers/TaskManager')
const adminStats = require('../utils/queries/adminStats')

//
// This is a controller for routes that don't have a home yet :(
//
class MiscController {
  constructor() { }

  /**
   * POST: /api/upload
   * Update library item
   * @param {*} req 
   * @param {*} res 
   */
  async handleUpload(req, res) {
    if (!req.user.canUpload) {
      Logger.warn('User attempted to upload without permission', req.user)
      return res.sendStatus(403)
    }
    if (!req.files) {
      Logger.error('Invalid request, no files')
      return res.sendStatus(400)
    }

    const files = Object.values(req.files)
    const { title, author, series, folder: folderId, library: libraryId } = req.body

    const library = await Database.libraryModel.getOldById(libraryId)
    if (!library) {
      return res.status(404).send(`Library not found with id ${libraryId}`)
    }
    const folder = library.folders.find(fold => fold.id === folderId)
    if (!folder) {
      return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`)
    }

    if (!files.length || !title) {
      return res.status(500).send(`Invalid post data`)
    }

    // Podcasts should only be one folder deep
    const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
    // `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
    // before sanitizing all the directory parts to remove illegal chars and finally prepending
    // the base folder path
    const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part))
    const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])

    await fs.ensureDir(outputDirectory)

    Logger.info(`Uploading ${files.length} files to`, outputDirectory)

    for (const file of files) {
      const path = Path.join(outputDirectory, sanitizeFilename(file.name))

      await file.mv(path)
        .then(() => {
          return true
        })
        .catch((error) => {
          Logger.error('Failed to move file', path, error)
          return false
        })
    }

    res.sendStatus(200)
  }

  /**
   * GET: /api/tasks
   * Get tasks for task manager
   * @param {*} req 
   * @param {*} res 
   */
  getTasks(req, res) {
    const includeArray = (req.query.include || '').split(',')

    const data = {
      tasks: TaskManager.tasks.map(t => t.toJSON())
    }

    if (includeArray.includes('queue')) {
      data.queuedTaskData = {
        embedMetadata: this.audioMetadataManager.getQueuedTaskData()
      }
    }

    res.json(data)
  }

  /**
   * PATCH: /api/settings
   * Update server settings
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  async updateServerSettings(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error('User other than admin attempting to update server settings', req.user)
      return res.sendStatus(403)
    }
    const settingsUpdate = req.body
    if (!isObject(settingsUpdate)) {
      return res.status(400).send('Invalid settings update object')
    }

    const madeUpdates = Database.serverSettings.update(settingsUpdate)
    if (madeUpdates) {
      await Database.updateServerSettings()

      // If backup schedule is updated - update backup manager
      if (settingsUpdate.backupSchedule !== undefined) {
        this.backupManager.updateCronSchedule()
      }
    }
    return res.json({
      success: true,
      serverSettings: Database.serverSettings.toJSONForBrowser()
    })
  }

  /**
   * PATCH: /api/sorting-prefixes
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  async updateSortingPrefixes(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error('User other than admin attempting to update server sorting prefixes', req.user)
      return res.sendStatus(403)
    }
    let sortingPrefixes = req.body.sortingPrefixes
    if (!sortingPrefixes?.length || !Array.isArray(sortingPrefixes)) {
      return res.status(400).send('Invalid request body')
    }
    sortingPrefixes = [...new Set(sortingPrefixes.map(p => p?.trim?.().toLowerCase()).filter(p => p))]
    if (!sortingPrefixes.length) {
      return res.status(400).send('Invalid sortingPrefixes in request body')
    }

    Logger.debug(`[MiscController] Updating sorting prefixes ${sortingPrefixes.join(', ')}`)
    Database.serverSettings.sortingPrefixes = sortingPrefixes
    await Database.updateServerSettings()

    let rowsUpdated = 0
    // Update titleIgnorePrefix column on books
    const books = await Database.bookModel.findAll({
      attributes: ['id', 'title', 'titleIgnorePrefix']
    })
    const bulkUpdateBooks = []
    books.forEach((book) => {
      const titleIgnorePrefix = getTitleIgnorePrefix(book.title)
      if (titleIgnorePrefix !== book.titleIgnorePrefix) {
        bulkUpdateBooks.push({
          id: book.id,
          titleIgnorePrefix
        })
      }
    })
    if (bulkUpdateBooks.length) {
      Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdateBooks.length} books`)
      rowsUpdated += bulkUpdateBooks.length
      await Database.bookModel.bulkCreate(bulkUpdateBooks, {
        updateOnDuplicate: ['titleIgnorePrefix']
      })
    }

    // Update titleIgnorePrefix column on podcasts
    const podcasts = await Database.podcastModel.findAll({
      attributes: ['id', 'title', 'titleIgnorePrefix']
    })
    const bulkUpdatePodcasts = []
    podcasts.forEach((podcast) => {
      const titleIgnorePrefix = getTitleIgnorePrefix(podcast.title)
      if (titleIgnorePrefix !== podcast.titleIgnorePrefix) {
        bulkUpdatePodcasts.push({
          id: podcast.id,
          titleIgnorePrefix
        })
      }
    })
    if (bulkUpdatePodcasts.length) {
      Logger.info(`[MiscController] Updating titleIgnorePrefix on ${bulkUpdatePodcasts.length} podcasts`)
      rowsUpdated += bulkUpdatePodcasts.length
      await Database.podcastModel.bulkCreate(bulkUpdatePodcasts, {
        updateOnDuplicate: ['titleIgnorePrefix']
      })
    }

    // Update nameIgnorePrefix column on series
    const allSeries = await Database.seriesModel.findAll({
      attributes: ['id', 'name', 'nameIgnorePrefix']
    })
    const bulkUpdateSeries = []
    allSeries.forEach((series) => {
      const nameIgnorePrefix = getTitleIgnorePrefix(series.name)
      if (nameIgnorePrefix !== series.nameIgnorePrefix) {
        bulkUpdateSeries.push({
          id: series.id,
          nameIgnorePrefix
        })
      }
    })
    if (bulkUpdateSeries.length) {
      Logger.info(`[MiscController] Updating nameIgnorePrefix on ${bulkUpdateSeries.length} series`)
      rowsUpdated += bulkUpdateSeries.length
      await Database.seriesModel.bulkCreate(bulkUpdateSeries, {
        updateOnDuplicate: ['nameIgnorePrefix']
      })
    }

    res.json({
      rowsUpdated,
      serverSettings: Database.serverSettings.toJSONForBrowser()
    })
  }

  /**
   * POST: /api/authorize
   * Used to authorize an API token
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  async authorize(req, res) {
    if (!req.user) {
      Logger.error('Invalid user in authorize')
      return res.sendStatus(401)
    }
    const userResponse = await this.auth.getUserLoginResponsePayload(req.user)
    res.json(userResponse)
  }

  /**
   * GET: /api/tags
   * Get all tags
   * @param {*} req 
   * @param {*} res 
   */
  async getAllTags(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
      return res.sendStatus(404)
    }

    const tags = []
    const books = await Database.bookModel.findAll({
      attributes: ['tags'],
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
        [Sequelize.Op.gt]: 0
      })
    })
    for (const book of books) {
      for (const tag of book.tags) {
        if (!tags.includes(tag)) tags.push(tag)
      }
    }

    const podcasts = await Database.podcastModel.findAll({
      attributes: ['tags'],
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('tags')), {
        [Sequelize.Op.gt]: 0
      })
    })
    for (const podcast of podcasts) {
      for (const tag of podcast.tags) {
        if (!tags.includes(tag)) tags.push(tag)
      }
    }

    res.json({
      tags: tags.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
    })
  }

  /**
   * POST: /api/tags/rename
   * Rename tag
   * Req.body { tag, newTag }
   * @param {*} req 
   * @param {*} res 
   */
  async renameTag(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
      return res.sendStatus(404)
    }

    const tag = req.body.tag
    const newTag = req.body.newTag
    if (!tag || !newTag) {
      Logger.error(`[MiscController] Invalid request body for renameTag`)
      return res.sendStatus(400)
    }

    let tagMerged = false
    let numItemsUpdated = 0

    // Update filter data
    Database.replaceTagInFilterData(tag, newTag)

    const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag])
    for (const libraryItem of libraryItemsWithTag) {
      if (libraryItem.media.tags.includes(newTag)) {
        tagMerged = true // new tag is an existing tag so this is a merge
      }

      if (libraryItem.media.tags.includes(tag)) {
        libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag
        if (!libraryItem.media.tags.includes(newTag)) {
          libraryItem.media.tags.push(newTag)
        }
        Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${libraryItem.media.title}"`)
        await libraryItem.media.update({
          tags: libraryItem.media.tags
        })
        await libraryItem.saveMetadataFile()
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
        SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
        numItemsUpdated++
      }
    }

    res.json({
      tagMerged,
      numItemsUpdated
    })
  }

  /**
   * DELETE: /api/tags/:tag
   * Remove a tag
   * :tag param is base64 encoded
   * @param {*} req 
   * @param {*} res 
   */
  async deleteTag(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
      return res.sendStatus(404)
    }

    const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()

    // Get all items with tag
    const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag])

    // Update filterdata
    Database.removeTagFromFilterData(tag)

    let numItemsUpdated = 0
    // Remove tag from items
    for (const libraryItem of libraryItemsWithTag) {
      Logger.debug(`[MiscController] Remove tag "${tag}" from item "${libraryItem.media.title}"`)
      libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag)
      await libraryItem.media.update({
        tags: libraryItem.media.tags
      })
      await libraryItem.saveMetadataFile()
      const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
      SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
      numItemsUpdated++
    }

    res.json({
      numItemsUpdated
    })
  }

  /**
   * GET: /api/genres
   * Get all genres
   * @param {*} req 
   * @param {*} res 
   */
  async getAllGenres(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
      return res.sendStatus(404)
    }
    const genres = []
    const books = await Database.bookModel.findAll({
      attributes: ['genres'],
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
        [Sequelize.Op.gt]: 0
      })
    })
    for (const book of books) {
      for (const tag of book.genres) {
        if (!genres.includes(tag)) genres.push(tag)
      }
    }

    const podcasts = await Database.podcastModel.findAll({
      attributes: ['genres'],
      where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('genres')), {
        [Sequelize.Op.gt]: 0
      })
    })
    for (const podcast of podcasts) {
      for (const tag of podcast.genres) {
        if (!genres.includes(tag)) genres.push(tag)
      }
    }

    res.json({
      genres
    })
  }

  /**
   * POST: /api/genres/rename
   * Rename genres
   * Req.body { genre, newGenre }
   * @param {*} req 
   * @param {*} res 
   */
  async renameGenre(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
      return res.sendStatus(404)
    }

    const genre = req.body.genre
    const newGenre = req.body.newGenre
    if (!genre || !newGenre) {
      Logger.error(`[MiscController] Invalid request body for renameGenre`)
      return res.sendStatus(400)
    }

    let genreMerged = false
    let numItemsUpdated = 0

    // Update filter data
    Database.replaceGenreInFilterData(genre, newGenre)

    const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre])
    for (const libraryItem of libraryItemsWithGenre) {
      if (libraryItem.media.genres.includes(newGenre)) {
        genreMerged = true // new genre is an existing genre so this is a merge
      }

      if (libraryItem.media.genres.includes(genre)) {
        libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre
        if (!libraryItem.media.genres.includes(newGenre)) {
          libraryItem.media.genres.push(newGenre)
        }
        Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${libraryItem.media.title}"`)
        await libraryItem.media.update({
          genres: libraryItem.media.genres
        })
        await libraryItem.saveMetadataFile()
        const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
        SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
        numItemsUpdated++
      }
    }

    res.json({
      genreMerged,
      numItemsUpdated
    })
  }

  /**
   * DELETE: /api/genres/:genre
   * Remove a genre
   * :genre param is base64 encoded
   * @param {*} req 
   * @param {*} res 
   */
  async deleteGenre(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
      return res.sendStatus(404)
    }

    const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()

    // Update filter data
    Database.removeGenreFromFilterData(genre)

    // Get all items with genre
    const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre])

    let numItemsUpdated = 0
    // Remove genre from items
    for (const libraryItem of libraryItemsWithGenre) {
      Logger.debug(`[MiscController] Remove genre "${genre}" from item "${libraryItem.media.title}"`)
      libraryItem.media.genres = libraryItem.media.genres.filter(g => g !== genre)
      await libraryItem.media.update({
        genres: libraryItem.media.genres
      })
      await libraryItem.saveMetadataFile()
      const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
      SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
      numItemsUpdated++
    }

    res.json({
      numItemsUpdated
    })
  }

  /**
   * POST: /api/watcher/update
   * Update a watch path
   * Req.body { libraryId, path, type, [oldPath] } 
   * type = add, unlink, rename
   * oldPath = required only for rename
   * @this import('../routers/ApiRouter')
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  updateWatchedPath(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user attempted to updateWatchedPath`)
      return res.sendStatus(404)
    }

    const libraryId = req.body.libraryId
    const path = req.body.path
    const type = req.body.type
    if (!libraryId || !path || !type) {
      Logger.error(`[MiscController] Invalid request body for updateWatchedPath. libraryId: "${libraryId}", path: "${path}", type: "${type}"`)
      return res.sendStatus(400)
    }

    switch (type) {
      case 'add':
        this.watcher.onFileAdded(libraryId, path)
        break
      case 'unlink':
        this.watcher.onFileRemoved(libraryId, path)
        break
      case 'rename':
        const oldPath = req.body.oldPath
        if (!oldPath) {
          Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`)
          return res.sendStatus(400)
        }
        this.watcher.onFileRename(libraryId, oldPath, path)
        break
      default:
        Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
        return res.sendStatus(400)
    }

    res.sendStatus(200)
  }

  validateCronExpression(req, res) {
    const expression = req.body.expression
    if (!expression) {
      return res.sendStatus(400)
    }

    try {
      patternValidation(expression)
      res.sendStatus(200)
    } catch (error) {
      Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
      res.status(400).send(error.message)
    }
  }

  /**
   * GET: api/auth-settings (admin only)
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  getAuthSettings(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`)
      return res.sendStatus(403)
    }
    return res.json(Database.serverSettings.authenticationSettings)
  }

  /**
   * PATCH: api/auth-settings
   * @this import('../routers/ApiRouter')
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  async updateAuthSettings(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to update auth settings`)
      return res.sendStatus(403)
    }

    const settingsUpdate = req.body
    if (!isObject(settingsUpdate)) {
      return res.status(400).send('Invalid auth settings update object')
    }

    let hasUpdates = false

    const currentAuthenticationSettings = Database.serverSettings.authenticationSettings
    const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods]

    // TODO: Better validation of auth settings once auth settings are separated from server settings
    for (const key in currentAuthenticationSettings) {
      if (settingsUpdate[key] === undefined) continue

      if (key === 'authActiveAuthMethods') {
        let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
        if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
          updatedAuthMethods.sort()
          currentAuthenticationSettings[key].sort()
          if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) {
            Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`)
            Database.serverSettings[key] = updatedAuthMethods
            hasUpdates = true
          }
        } else {
          Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
        }
      } else if (key === 'authOpenIDMobileRedirectURIs') {
        function isValidRedirectURI(uri) {
          if (typeof uri !== 'string') return false
          const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i')
          return pattern.test(uri)
        }

        const uris = settingsUpdate[key]
        if (!Array.isArray(uris) ||
          (uris.includes('*') && uris.length > 1) ||
          uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) {
          Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`)
          continue
        }

        // Update the URIs
        if (Database.serverSettings[key].some(uri => !uris.includes(uri)) || uris.some(uri => !Database.serverSettings[key].includes(uri))) {
          Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`)
          Database.serverSettings[key] = uris
          hasUpdates = true
        }
      } else {
        const updatedValueType = typeof settingsUpdate[key]
        if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
          if (updatedValueType !== 'boolean') {
            Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`)
            continue
          }
        } else if (settingsUpdate[key] !== null && updatedValueType !== 'string') {
          Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`)
          continue
        }
        let updatedValue = settingsUpdate[key]
        if (updatedValue === '') updatedValue = null
        let currentValue = currentAuthenticationSettings[key]
        if (currentValue === '') currentValue = null

        if (updatedValue !== currentValue) {
          Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
          Database.serverSettings[key] = updatedValue
          hasUpdates = true
        }
      }
    }

    if (hasUpdates) {
      await Database.updateServerSettings()

      // Use/unuse auth methods
      Database.serverSettings.supportedAuthMethods.forEach((authMethod) => {
        if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
          // Auth method has been removed
          Logger.info(`[MiscController] Disabling active auth method "${authMethod}"`)
          this.auth.unuseAuthStrategy(authMethod)
        } else if (!originalAuthMethods.includes(authMethod) && Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
          // Auth method has been added
          Logger.info(`[MiscController] Enabling active auth method "${authMethod}"`)
          this.auth.useAuthStrategy(authMethod)
        }
      })
    }

    res.json({
      updated: hasUpdates,
      serverSettings: Database.serverSettings.toJSONForBrowser()
    })
  }

  /**
   * GET: /api/stats/year/:year
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  async getAdminStatsForYear(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin stats for year`)
      return res.sendStatus(403)
    }
    const year = Number(req.params.year)
    if (isNaN(year) || year < 2000 || year > 9999) {
      Logger.error(`[MiscController] Invalid year "${year}"`)
      return res.status(400).send('Invalid year')
    }
    const stats = await adminStats.getStatsForYear(year)
    res.json(stats)
  }

  /**
   * GET: /api/logger-data
   * admin or up
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  async getLoggerData(req, res) {
    if (!req.user.isAdminOrUp) {
      Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get logger data`)
      return res.sendStatus(403)
    }

    res.json({
      currentDailyLogs: Logger.logManager.getMostRecentCurrentDailyLogs()
    })
  }
}
module.exports = new MiscController()