Level = require './../models/Level'
Session = require './../models/LevelSession'
User = require '../models/User'
SessionHandler = require './level_session_handler'
Feedback = require './../models/LevelFeedback'
Handler = require '../commons/Handler'
mongoose = require 'mongoose'
async = require 'async'
utils = require '../lib/utils'
log = require 'winston'
Campaign = require '../models/Campaign'
Course = require  '../models/Course'
CourseInstance = require '../models/CourseInstance'
Classroom = require '../models/Classroom'

LevelHandler = class LevelHandler extends Handler
  modelClass: Level
  jsonSchema: require '../../app/schemas/models/level'
  editableProperties: Level.editableProperties
  postEditableProperties: Level.postEditableProperties

  getByRelationship: (req, res, args...) ->
    return @getSession(req, res, args[0]) if args[1] is 'session'
    return @getLeaderboard(req, res, args[0]) if args[1] is 'leaderboard'
    return @getMyLeaderboardRank(req, res, args[0]) if args[1] is 'leaderboard_rank'
    return @getMySessions(req, res, args[0]) if args[1] is 'my_sessions'
    return @getFeedback(req, res, args[0]) if args[1] is 'feedback'
    return @getAllFeedback(req, res, args[0]) if args[1] is 'all_feedback'
    return @getRandomSessionPair(req, res, args[0]) if args[1] is 'random_session_pair'
    return @getLeaderboardFacebookFriends(req, res, args[0]) if args[1] is 'leaderboard_facebook_friends'
    return @getLeaderboardGPlusFriends(req, res, args[0]) if args[1] is 'leaderboard_gplus_friends'
    return @getHistogramData(req, res, args[0]) if args[1] is 'histogram_data'
    return @checkExistence(req, res, args[0]) if args[1] is 'exists'
    return @getPlayCountsBySlugs(req, res) if args[1] is 'play_counts'
    return @getLevelPlaytimesBySlugs(req, res) if args[1] is 'playtime_averages'
    return @getTopScores(req, res, args[0], args[2], args[3]) if args[1] is 'top_scores'
    super(arguments...)

  fetchLevelByIDAndHandleErrors: (id, req, res, callback) ->
    # TODO: this could probably be faster with projections, right?
    @getDocumentForIdOrSlug id, (err, level) =>
      return @sendDatabaseError(res, err) if err
      return @sendNotFoundError(res) unless level?
      return @sendForbiddenError(res) unless @hasAccessToDocument(req, level, 'get')
      callback err, level

  getSession: (req, res, id) ->
    return @sendNotFoundError(res) unless req.user
    @fetchLevelByIDAndHandleErrors id, req, res, (err, level) =>
      sessionQuery =
        level:
          original: level.original.toString()
          majorVersion: level.version.major
        creator: req.user.id

      if req.query.team?
        sessionQuery.team = req.query.team

      Session.findOne(sessionQuery).exec (err, doc) =>
        return @sendDatabaseError(res, err) if err
        return @sendSuccess(res, doc) if doc?
        if level.get('type') in ['course', 'course-ladder'] or req.query.course?
          return @makeOrRejectCourseLevelSession(req, res, level, sessionQuery)
        requiresSubscription = level.get('requiresSubscription') or (req.user.isOnPremiumServer() and level.get('campaign') and not (level.slug in ['dungeons-of-kithgard', 'gems-in-the-deep', 'shadow-guard', 'forgetful-gemsmith', 'signs-and-portents', 'true-names']))
        canPlayAnyway = req.user.isPremium() or level.get 'adventurer'
        return @sendPaymentRequiredError(res, err) if requiresSubscription and not canPlayAnyway
        @createAndSaveNewSession sessionQuery, req, res

  makeOrRejectCourseLevelSession: (req, res, level, sessionQuery) ->
    CourseInstance.find {members: req.user.get('_id')}, (err, courseInstances) =>
      courseIDs = (ci.get('courseID') for ci in courseInstances)
      Course.find { _id: { $in: courseIDs }}, (err, courses) =>
        campaignIDs = (c.get('campaignID') for c in courses)
        Campaign.find { _id: { $in: campaignIDs }}, (err, campaigns) =>
          levelOriginals = (_.keys(c.get('levels')) for c in campaigns)
          levelOriginals = _.flatten(levelOriginals)
          originalString = level.get('original').toString()
          if originalString in levelOriginals
            campaignStrings = (campaign.id.toString() for campaign in campaigns when campaign.get('levels')[originalString])
            courses = _.filter(courses, (course) -> course.get('campaignID').toString() in campaignStrings)
            courseStrings = (course.id.toString() for course in courses)
            courseInstances = _.filter(courseInstances, (courseInstance) -> courseInstance.get('courseID').toString() in courseStrings)
            classroomIDs = (courseInstance.get('classroomID') for courseInstance in courseInstances)
            classroomIDs = _.filter _.uniq classroomIDs, false, (objectID='') -> objectID.toString()
            if classroomIDs.length
              Classroom.find({ _id: { $in: classroomIDs }}).exec (err, classrooms) =>
                aceConfigs = (c.get('aceConfig') for c in classrooms)
                aceConfig = _.filter(aceConfigs)[0] or {}
                req.codeLanguage = aceConfig.language
                @createAndSaveNewSession(sessionQuery, req, res)
            else
              @createAndSaveNewSession(sessionQuery, req, res)
          else
            return @sendPaymentRequiredError(res, 'You must be in a course which includes this level to play it')

  createAndSaveNewSession: (sessionQuery, req, res) =>
    initVals = sessionQuery

    initVals.state =
      complete: false
      scripts:
        currentScript: null # will not save empty objects

    initVals.permissions = [
      {
        target: req.user.id
        access: 'owner'
      }
      {
        target: 'public'
        access: 'write'
      }
    ]
    initVals.codeLanguage = req.codeLanguage ? req.user.get('aceConfig')?.language ? 'python'
    session = new Session(initVals)

    session.save (err) =>
      return @sendDatabaseError(res, err) if err
      @sendSuccess(res, @formatEntity(req, session))
      # TODO: tying things like @formatEntity and saveChangesToDocument don't make sense
      # associated with the handler, because the handler might return a different type
      # of model, like in this case. Refactor to move that logic to the model instead.

  getMySessions: (req, res, slugOrID) ->
    return @sendForbiddenError(res) if not req.user
    findParameters = {}
    if Handler.isID slugOrID
      findParameters['_id'] = slugOrID
    else
      findParameters['slug'] = slugOrID
    selectString = 'original version.major permissions'
    query = Level.findOne(findParameters)
      .select(selectString)
      .lean()

    query.exec (err, level) =>
      return @sendDatabaseError(res, err) if err
      return @sendNotFoundError(res) unless level?
      sessionQuery =
        level:
          original: level.original.toString()
          majorVersion: level.version.major
        creator: req.user._id+''

      query = Session.find(sessionQuery)
      # TODO: take out "code" as well, since that can get huge containing the transpiled code for the lat hero, and find another way of having the LadderSubmissionViews in the MyMatchesTab determine ranking readiness
      query.exec (err, results) =>
        if err then @sendDatabaseError(res, err) else @sendSuccess res, results

  getHistogramData: (req, res, id) ->
    match = @makeLeaderboardQueryParameters req, id
    delete match.totalScore
    project = totalScore: 1, _id: 0
    league = req.query['leagues.leagueID']
    project['leagues.leagueID'] = project['leagues.stats.totalScore'] = 1 if league
    aggregate = Session.aggregate [
      {$match: match}
      {$project: project}
    ]
    aggregate.cache(10 * 60 * 1000) unless league

    aggregate.exec (err, data) =>
      if err? then return @sendDatabaseError res, err
      if league
        valueArray = _.pluck data, (session) -> _.find(session.leagues, leagueID: league)?.stats?.totalScore or 10
      else
        valueArray = _.pluck data, 'totalScore'
      @sendSuccess res, valueArray

  checkExistence: (req, res, slugOrID) ->
    findParameters = {}
    if Handler.isID slugOrID
      findParameters['_id'] = slugOrID
    else
      findParameters['slug'] = slugOrID
    selectString = 'original version.major permissions'
    query = Level.findOne(findParameters)
    .select(selectString)
    .lean()

    query.exec (err, level) =>
      return @sendDatabaseError(res, err) if err
      return @sendNotFoundError(res) unless level?
      res.send({'exists': true})
      res.end()

  getLeaderboard: (req, res, id) ->
    sessionsQueryParameters = @makeLeaderboardQueryParameters(req, id)
    sortParameters = totalScore: req.query.order
    selectProperties = ['totalScore', 'creatorName', 'creator', 'submittedCodeLanguage', 'heroConfig', 'leagues.leagueID', 'leagues.stats.totalScore', 'submitDate', 'team']
    query = Session
      .find(sessionsQueryParameters)
      .limit(req.query.limit)
      .sort(sortParameters)
      .select(selectProperties.join ' ')
    query.cache(5 * 60 * 1000) if sessionsQueryParameters.totalScore.$lt is 1000000

    query.exec (err, resultSessions) =>
      return @sendDatabaseError(res, err) if err
      resultSessions ?= []
      leaderboardOptions = find: sessionsQueryParameters, limit: req.query.limit, sort: sortParameters, select: selectProperties
      @interleaveAILeaderboardSessions leaderboardOptions, resultSessions, (err, resultSessions) =>
        return @sendDatabaseError(res, err) if err
        if league = req.query['leagues.leagueID']
          resultSessions = _.sortBy resultSessions, (session) -> _.find(session.get('leagues'), leagueID: league)?.stats.totalScore ? session.get('totalScore') / 2
          resultSessions.reverse() if sortParameters.totalScore is -1
        @sendSuccess res, resultSessions

  getMyLeaderboardRank: (req, res, id) ->
    req.query.order = 1
    sessionsQueryParameters = @makeLeaderboardQueryParameters(req, id)
    Session.count sessionsQueryParameters, (err, count) =>
      return @sendDatabaseError(res, err) if err
      res.send JSON.stringify(count + 1)

  makeLeaderboardQueryParameters: (req, id) ->
    @validateLeaderboardRequestParameters req
    [original, version] = id.split '.'
    version = parseInt(version) ? 0
    scoreQuery = {}
    scoreQuery[if req.query.order is 1 then '$gt' else '$lt'] = req.query.scoreOffset
    query =
      level:
        original: original
        majorVersion: version
      team: req.query.team
      totalScore: scoreQuery
      submitted: true
    query['leagues.leagueID'] = league if league = req.query['leagues.leagueID']
    query

  validateLeaderboardRequestParameters: (req) ->
    req.query.order = parseInt(req.query.order) ? -1
    req.query.scoreOffset = parseFloat(req.query.scoreOffset) ? 100000
    req.query.team ?= 'humans'
    req.query.limit = parseInt(req.query.limit) ? 20

  ladderBenchmarkAIs: [
    '564ba6cea33967be1312ae59'
    '564ba830a33967be1312ae61'
    '564ba91aa33967be1312ae65'
    '564ba95ca33967be1312ae69'
    '564ba9b7a33967be1312ae6d'
  ]

  interleaveAILeaderboardSessions: (leaderboardOptions, sessions, cb) ->
    return cb null, sessions unless leaderboardOptions.find['leagues.leagueID']
    return cb null, sessions if leaderboardOptions.limit < 10  # Don't put them in when we're fetching sessions around another session
    # Get our list of benchmark AI sessions
    benchmarkSessions = Session
      .find(level: leaderboardOptions.find.level, creator: {$in: @ladderBenchmarkAIs})
      .sort(leaderboardOptions.sort)
      .select(leaderboardOptions.select.join ' ')
      .cache(30 * 60 * 1000)
      .exec (err, aiSessions) ->
        return cb err if err
        matchingAISessions = _.filter aiSessions, (aiSession) ->
          return false unless aiSession.get('team') is leaderboardOptions.find.team
          return false if $gt = leaderboardOptions.find.totalScore.$gt and aiSession.get('totalScore') <= $gt
          return false if $lt = leaderboardOptions.find.totalScore.$lt and aiSession.get('totalScore') >= $lt
          true
        # TODO: these aren't real league scores for AIs, but rather the general leaderboard scores, which will make most AI scores artificially high. So we divide by 2 for AI scores not part of the league. Pretty weak, I know. Eventually we'd want them to actually play league matches as if they were in all leagues, but without having infinite space requirements or something? Or change the UI to take them out of the main league table and into their separate area.
        sessions = _.sortBy sessions.concat(matchingAISessions), (session) -> _.find(session.get('leagues'), leagueID: leaderboardOptions.find['leagues.leagueID'])?.stats.totalScore ? session.get('totalScore') / 2
        sessions.reverse() if leaderboardOptions.sort.totalScore is -1
        sessions = sessions.slice 0, leaderboardOptions.limit
        return cb null, sessions

  getLeaderboardFacebookFriends: (req, res, id) -> @getLeaderboardFriends(req, res, id, 'facebookID')
  getLeaderboardGPlusFriends: (req, res, id) -> @getLeaderboardFriends(req, res, id, 'gplusID')
  getLeaderboardFriends: (req, res, id, serviceProperty) ->
    friendIDs = req.body.friendIDs or []
    return res.send([]) unless friendIDs.length

    q = {}
    q[serviceProperty] = {$in: friendIDs}
    query = User.find(q).select("#{serviceProperty} name").lean()

    query.exec (err, userResults) ->
      return res.send([]) unless userResults.length
      [id, version] = id.split('.')
      userIDs = (r._id+'' for r in userResults)
      q = {'level.original': id, 'level.majorVersion': parseInt(version), creator: {$in: userIDs}, totalScore: {$exists: true}}
      query = Session.find(q)
      .select('creator creatorName totalScore team')
      .lean()

      query.exec (err, sessionResults) ->
        return res.send([]) unless sessionResults.length
        userMap = {}
        userMap[u._id] = u[serviceProperty] for u in userResults
        session[serviceProperty] = userMap[session.creator] for session in sessionResults
        res.send(sessionResults)

  getRandomSessionPair: (req, res, slugOrID) ->
    findParameters = {}
    if Handler.isID slugOrID
      findParameters['_id'] = slugOrID
    else
      findParameters['slug'] = slugOrID
    selectString = 'original version'
    query = Level.findOne(findParameters)
      .select(selectString)
      .lean()
      .cache(60 * 60 * 1000)

    query.exec (err, level) =>
      return @sendDatabaseError(res, err) if err
      return @sendNotFoundError(res) unless level?

      sessionsQueryParameters =
        level:
          original: level.original.toString()
          majorVersion: level.version.major
        submitted: true

      teams = ['humans', 'ogres']
      findTop20Players = (sessionQueryParams, team, cb) ->
        sessionQueryParams['team'] = team
        aggregate = Session.aggregate [
          {$match: sessionQueryParams}
          {$sort: {'totalScore': -1}}
          {$limit: 20}
          {$project: {'totalScore': 1}}
        ]
        aggregate.cache(3 * 60 * 1000)
        aggregate.exec cb

      async.map teams, findTop20Players.bind(@, sessionsQueryParameters), (err, map) =>
        if err? then return @sendDatabaseError(res, err)
        sessions = []
        for mapItem in map
          sessions.push _.sample(mapItem)
        if map.length != 2 then return @sendDatabaseError res, 'There aren\'t sessions of 2 teams, so cannot choose random opponents!'
        @sendSuccess res, sessions

  getFeedback: (req, res, levelID) ->
    return @sendNotFoundError(res) unless req.user
    @doGetFeedback req, res, levelID, false

  getAllFeedback: (req, res, levelID) ->
    return @sendNotFoundError(res) unless req.user
    @doGetFeedback req, res, levelID, true

  doGetFeedback: (req, res, levelID, multiple) ->
    @fetchLevelByIDAndHandleErrors levelID, req, res, (err, level) =>
      feedbackQuery =
        'level.original': level.original.toString()
        'level.majorVersion': level.version.major
      feedbackQuery.creator = mongoose.Types.ObjectId(req.user.id.toString()) unless multiple
      fn = if multiple then 'find' else 'findOne'
      Feedback[fn](feedbackQuery).exec (err, result) =>
        return @sendDatabaseError(res, err) if err
        return @sendNotFoundError(res) unless result?
        @sendSuccess(res, result)

  getPlayCountsBySlugs: (req, res) ->
    # This is hella slow (4s on my box), so relying on some dumb caching for it.
    # If we can't make this faster with indexing or something, we might want to maintain the counts another way.
    levelIDs = req.query.ids or req.body.ids
    return @sendSuccess res, [] unless levelIDs?

    @playCountCache ?= {}
    @playCountCachedSince ?= new Date()
    if (new Date()) - @playCountCachedSince > 86400 * 1000  # Dumb cache expiration
      @playCountCache = {}
      @playCountCachedSince = new Date()
    cacheKey = levelIDs.join ','
    if playCounts = @playCountCache[cacheKey]
      return @sendSuccess res, playCounts
    query = Session.aggregate [
      {$match: {levelID: {$in: levelIDs}}}
      {$group: {_id: "$levelID", playtime: {$sum: "$playtime"}, sessions: {$sum: 1}}}
      {$sort: {sessions: -1}}
    ]
    query.exec (err, data) =>
      if err? then return @sendDatabaseError res, err
      @playCountCache[cacheKey] = data
      @sendSuccess res, data

  hasAccessToDocument: (req, document, method=null) ->
    return true if req.user?.isArtisan()
    method ?= req.method
    return true if method is null or method is 'get'
    super(req, document, method)

  getLevelPlaytimesBySlugs: (req, res) ->
    # Returns an array of per-day level average playtimes
    # Parameters:
    # slugs - array of level slugs
    # startDay - Inclusive, optional, e.g. '2014-12-14'
    # endDay - Exclusive, optional, e.g. '2014-12-16'

    # TODO: An uncached call takes about 5s for dungeons-of-kithgard locally
    # TODO: This is very similar to getLevelCompletionsBySlugs(), time to generalize analytics APIs?

    # TODO: exclude admin data

    levelSlugs = req.query.slugs or req.body.slugs
    startDay = req.query.startDay or req.body.startDay
    endDay = req.query.endDay or req.body.endDay

    return @sendSuccess res, [] unless levelSlugs?

    # log.warn "playtime_averages levelSlugs='#{levelSlugs}' startDay=#{startDay} endDay=#{endDay}"

    # Cache results for 1 day
    @levelPlaytimesCache ?= {}
    @levelPlaytimesCachedSince ?= new Date()
    if (new Date()) - @levelPlaytimesCachedSince > 86400 * 1000  # Dumb cache expiration
      @levelPlaytimesCache = {}
      @levelPlaytimesCachedSince = new Date()
    cacheKey = levelSlugs.join(',')
    cacheKey += 's' + startDay if startDay?
    cacheKey += 'e' + endDay if endDay?
    return @sendSuccess res, levelPlaytimes if levelPlaytimes = @levelPlaytimesCache[cacheKey]

    # Build query
    match = {$match: {$and: [{"state.complete": true}, {"playtime": {$gt: 0}}, {levelID: {$in: levelSlugs}}]}}
    match["$match"]["$and"].push _id: {$gte: utils.objectIdFromTimestamp(startDay + "T00:00:00.000Z")} if startDay?
    match["$match"]["$and"].push _id: {$lt: utils.objectIdFromTimestamp(endDay + "T00:00:00.000Z")} if endDay?
    project = {"$project": {"_id": 0, "levelID": 1, "playtime": 1, "created": {"$concat": [{"$substr":  ["$created", 0, 10]}]}}}
    group = {"$group": {"_id": {"created": "$created", "level": "$levelID"}, "average": {"$avg": "$playtime"}}}
    query = Session.aggregate match, project, group

    query.exec (err, data) =>
      if err? then return @sendDatabaseError res, err

      # Build list of level average playtimes
      playtimes = []
      for item in data
        playtimes.push
          level: item._id.level
          created: item._id.created
          average: item.average
      @levelPlaytimesCache[cacheKey] = playtimes
      @sendSuccess res, playtimes

  getTopScores: (req, res, levelOriginal, scoreType, timespan) ->
    query =
      'level.original': levelOriginal
      'state.topScores.type': scoreType
    now = new Date()
    if timespan is 'day'
      since = new Date now - 1 * 86400 * 1000
    else if timespan is 'week'
      since = new Date now - 7 * 86400 * 1000
    if since
      query['state.topScores.date'] = $gt: since.toISOString()

    sort =
      'state.topScores.score': -1

    select = ['state.topScores', 'creatorName', 'creator', 'codeLanguage', 'heroConfig']

    query = Session
      .find(query)
      .limit(20)
      .sort(sort)
      .select(select.join ' ')

    query.exec (err, resultSessions) =>
      return @sendDatabaseError(res, err) if err
      resultSessions ?= []
      @sendSuccess res, resultSessions


module.exports = new LevelHandler()
