schema = require '../../app/schemas/models/user'
crypto = require 'crypto'
request = require 'request'
User = require './../models/User'
Handler = require '../commons/Handler'
mongoose = require 'mongoose'
config = require '../../server_config'
errors = require '../commons/errors'
async = require 'async'
log = require 'winston'
moment = require 'moment'
AnalyticsLogEvent = require '../models/AnalyticsLogEvent'
Clan = require '../models/Clan'
CourseInstance = require '../models/CourseInstance'
LevelSession = require '../models/LevelSession'
LevelSessionHandler = require './level_session_handler'
Payment = require '../models/Payment'
SubscriptionHandler = require './subscription_handler'
DiscountHandler = require './discount_handler'
EarnedAchievement = require '../models/EarnedAchievement'
UserRemark = require './../models/UserRemark'
{findStripeSubscription} = require '../lib/utils'
{isID} = require '../lib/utils'
slack = require '../slack'
sendwithus = require '../sendwithus'
Prepaid = require '../models/Prepaid'
UserPollsRecord = require '../models/UserPollsRecord'
EarnedAchievement = require '../models/EarnedAchievement'

serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP']
candidateProperties = [
  'jobProfile', 'jobProfileApproved', 'jobProfileNotes'
]

UserHandler = class UserHandler extends Handler
  modelClass: User

  allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']

  getEditableProperties: (req, document) ->
    props = super req, document
    props.push 'permissions' unless config.isProduction or global.testing
    props.push 'jobProfileApproved', 'jobProfileNotes','jobProfileApprovedDate' if req.user.isAdmin()  # Admins naturally edit these
    props.push @privateProperties... if req.user.isAdmin()  # Admins are mad with power
    if not req.user.isAdmin()
      if document.isTeacher() and req.body.role not in User.teacherRoles
        props = _.without props, 'role'
    props

  formatEntity: (req, document, publicOnly=false) =>
    # TODO: Delete. This function is duplicated in server User model toObject transform.
    return null unless document?
    obj = document.toObject()
    delete obj[prop] for prop in User.serverProperties
    includePrivates = not publicOnly and (req.user and (req.user.isAdmin() or req.user._id.equals(document._id) or req.session.amActually is document.id))
    delete obj[prop] for prop in User.privateProperties unless includePrivates
    includeCandidate = not publicOnly and (includePrivates or (obj.jobProfile?.active and req.user and ('employer' in (req.user.get('permissions') ? [])) and @employerCanViewCandidate req.user, obj))
    delete obj[prop] for prop in User.candidateProperties unless includeCandidate
    return obj

  waterfallFunctions: [
    # FB access token checking
    # Check the email is the same as FB reports
    (req, user, callback) ->
      fbID = req.query.facebookID
      fbAT = req.query.facebookAccessToken
      return callback(null, req, user) unless fbID and fbAT
      url = "https://graph.facebook.com/me?access_token=#{fbAT}"
      request(url, (err, response, body) ->
        log.warn "Error grabbing FB token: #{err}" if err
        body = JSON.parse(body)
        emailsMatch = req.body.email is body.email
        return callback(res: 'Invalid Facebook Access Token.', code: 422) unless emailsMatch
        callback(null, req, user)
      )

    # GPlus access token checking
    (req, user, callback) ->
      gpID = req.query.gplusID
      gpAT = req.query.gplusAccessToken
      return callback(null, req, user) unless gpID and gpAT
      url = "https://www.googleapis.com/oauth2/v2/userinfo?access_token=#{gpAT}"
      request(url, (err, response, body) ->
        log.warn "Error grabbing G+ token: #{err}" if err
        body = JSON.parse(body)
        emailsMatch = req.body.email is body.email
        return callback(res: 'Invalid G+ Access Token.', code: 422) unless emailsMatch
        callback(null, req, user)
      )

    # Email setting
    (req, user, callback) ->
      return callback(null, req, user) unless req.body.email?
      
      # handle unsetting email
      if req.body.email is ''
        user.set('email', req.body.email)
        return callback(null, req, user)
        
      emailLower = req.body.email.toLowerCase()
      return callback(null, req, user) if emailLower is user.get('emailLower')
      User.findOne({emailLower: emailLower}).exec (err, otherUser) ->
        log.error "Database error setting user email: #{err}" if err
        return callback(res: 'Database error.', code: 500) if err

        if (req.query.gplusID or req.query.facebookID) and otherUser
          # special case, log in as that user
          return req.logIn(otherUser, (err) ->
            return callback(res: 'Facebook user login error.', code: 500) if err
            return callback(null, req, otherUser)
          )
        r = {message: 'is already used by another account', property: 'email', code: 409}
        return callback({res: r, code: 409}) if otherUser
        user.set('email', req.body.email)
        callback(null, req, user)

    # Name setting
    (req, user, callback) ->
      return callback(null, req, user) unless req.body.name?
      
      if req.body.name is ''
        user.set('name', req.body.name)
        return callback(null, req, user)
      
      nameLower = req.body.name?.toLowerCase()
      return callback(null, req, user) unless nameLower?
      return callback(null, req, user) if user.get 'anonymous' # anonymous users can have any name
      return callback(null, req, user) if nameLower is user.get('nameLower')
      User.findOne({nameLower: nameLower, anonymous: false}).exec (err, otherUser) ->
        log.error "Database error setting user name: #{err}" if err
        return callback(res: 'Database error.', code: 500) if err
        r = {message: 'is already used by another account', property: 'name', code: 409}
        log.info 'Another user exists' if otherUser
        return callback({res: r, code: 409}) if otherUser
        user.set('name', req.body.name)
        callback(null, req, user)

    # Subscription setting
    (req, user, callback) ->
      # TODO: Make subscribe vs. unsubscribe explicit.  This property dance is confusing.
      return callback(null, req, user) unless req.headers['x-change-plan'] # ensure only saves that are targeted at changing the subscription actually affect the subscription
      return callback(null, req, user) unless req.body.stripe
      finishSubscription = (hasPlan, wantsPlan) ->
        return callback(null, req, user) if hasPlan is wantsPlan
        if wantsPlan and not hasPlan
          SubscriptionHandler.subscribeUser(req, user, (err) ->
            return callback(err) if err
            return callback(null, req, user)
          )
        else if hasPlan and not wantsPlan
          SubscriptionHandler.unsubscribeUser(req, user, (err) ->
            return callback(err) if err
            return callback(null, req, user)
          )
      if req.body.stripe.subscribeEmails?
        SubscriptionHandler.subscribeUser(req, user, (err) ->
          return callback(err) if err
          return callback(null, req, user)
        )
      else if req.body.stripe.unsubscribeEmail?
        SubscriptionHandler.unsubscribeUser(req, user, (err) ->
          return callback(err) if err
          return callback(null, req, user)
        )
      else
        wantsPlan = req.body.stripe.planID?
        hasPlan = user.get('stripe')?.planID? and not req.body.stripe.prepaidCode?
        finishSubscription hasPlan, wantsPlan

    # Discount setting
    (req, user, callback) ->
      return callback(null, req, user) unless req.body.stripe
      return callback(null, req, user) unless req.user?.isAdmin()
      hasCoupon = user.get('stripe')?.couponID
      wantsCoupon = req.body.stripe.couponID

      return callback(null, req, user) if hasCoupon is wantsCoupon
      if wantsCoupon and (hasCoupon isnt wantsCoupon)
        DiscountHandler.discountUser(req, user, (err) ->
          return callback(err) if err
          return callback(null, req, user)
        )
      else if hasCoupon and not wantsCoupon
        DiscountHandler.removeDiscountFromCustomer(req, user, (err) ->
          return callback(err) if err
          return callback(null, req, user)
        )

  ]

  getById: (req, res, id) ->
    if Handler.isID(id) and req.user?._id.equals(id)
      return @sendSuccess(res, @formatEntity(req, req.user))
    super(req, res, id)

  getByIDs: (req, res) ->
    return @sendForbiddenError(res) unless req.user?.isAdmin()
    User.find {_id: {$in: req.body.ids}}, (err, users) =>
      return @sendDatabaseError(res, err) if err
      cleandocs = (@formatEntity(req, doc) for doc in users)
      @sendSuccess(res, cleandocs)

  getNamesByIDs: (req, res) ->
    ids = req.query.ids or req.body.ids
    returnWizard = req.query.wizard or req.body.wizard
    properties = if returnWizard then 'name wizard firstName lastName' else 'name firstName lastName'
    @getPropertiesFromMultipleDocuments res, User, properties, ids

  nameToID: (req, res, name) ->
    User.findOne({nameLower: unescape(name).toLowerCase(), anonymous: false}).exec (err, otherUser) ->
      res.send(if otherUser then otherUser._id else JSON.stringify(''))
      res.end()

  getSimulatorLeaderboard: (req, res) ->
    queryParameters = @getSimulatorLeaderboardQueryParameters(req)
    leaderboardQuery = User.find(queryParameters.query).select('name simulatedBy simulatedFor').sort({'simulatedBy': queryParameters.sortOrder}).limit(queryParameters.limit)
    leaderboardQuery.cache(10 * 60 * 1000) if req.query.scoreOffset is -1
    leaderboardQuery.exec (err, otherUsers) ->
      otherUsers = _.reject otherUsers, _id: req.user._id if req.query.scoreOffset isnt -1 and req.user
      otherUsers ?= []
      res.send(otherUsers)
      res.end()

  getMySimulatorLeaderboardRank: (req, res) ->
    req.query.order = 1
    queryParameters = @getSimulatorLeaderboardQueryParameters(req)
    User.count queryParameters.query, (err, count) =>
      return @sendDatabaseError(res, err) if err
      res.send JSON.stringify(count + 1)

  getSimulatorLeaderboardQueryParameters: (req) ->
    @validateSimulateLeaderboardRequestParameters(req)

    query = {}
    sortOrder = -1
    limit = if req.query.limit > 30 then 30 else req.query.limit
    if req.query.scoreOffset isnt -1
      simulatedByQuery = {}
      simulatedByQuery[if req.query.order is 1 then '$gt' else '$lte'] = req.query.scoreOffset
      query.simulatedBy = simulatedByQuery
      sortOrder = 1 if req.query.order is 1
    else
      query.simulatedBy = {'$exists': true}
    {query: query, sortOrder: sortOrder, limit: limit}

  validateSimulateLeaderboardRequestParameters: (req) ->
    req.query.order = parseInt(req.query.order) ? -1
    req.query.scoreOffset = parseFloat(req.query.scoreOffset) ? 100000
    req.query.limit = parseInt(req.query.limit) ? 20

  post: (req, res) ->
    return @sendBadInputError(res, 'No input.') if _.isEmpty(req.body)
    return @sendBadInputError(res, 'Must have an anonymous user to post with.') unless req.user
    return @sendBadInputError(res, 'Existing users cannot create new ones.') if req.user.get('anonymous') is false
    req.body._id = req.user._id if req.user.get('anonymous')
    @put(req, res)

  hasAccessToDocument: (req, document) ->
    if req.route.method in ['put', 'post', 'patch', 'delete']
      return true if req.user?.isAdmin()
      return req.user?._id.equals(document._id)
    return true

  delete: (req, res, userID) ->
    # Instead of just deleting the User object, we should remove all the properties except for _id
    # And add a `deleted: true` property
    @getDocumentForIdOrSlug userID, (err, user) => # Check first
      return @sendDatabaseError res, err if err
      return @sendNotFoundError res unless user
      return @sendForbiddenError res unless @hasAccessToDocument(req, user)

      # Delete subscriptions attached to this user first
      # TODO: check sponsored subscriptions (stripe.recipients)
      checkPersonalSubscription = (user, done) =>
        try
          return done() unless user.get('stripe')?.subscriptionID?
          SubscriptionHandler.unsubscribeUser {body: user.toObject()}, user, (err) =>
            log.error("User delete check personal sub " + err) if err
            done()
        catch error
          log.error("User delete check personal sub " + error)
          done()
      checkRecipientSubscription = (user, done) =>
        try
          return done() unless sponsorID = user.get('stripe')?.sponsorID
          User.findById sponsorID, (err, sponsor) =>
            if err
              log.error("User delete check recipient sub " + err)
              return done()
            unless sponsor
              log.error("User delete check recipient sub no sponsor #{user.get('stripe').sponsorID}")
              return done()
            sponsorObject = sponsor.toObject()
            sponsorObject.stripe.unsubscribeEmail = user.get('email')
            SubscriptionHandler.unsubscribeRecipient {body: sponsorObject}, sponsor, (err) =>
              log.error("User delete check recipient sub " + err) if err
              done()
        catch error
          log.error("User delete check recipient sub " + error)
          done()
      deleteSubscriptions = (user, done) =>
        checkPersonalSubscription user, (err) =>
          checkRecipientSubscription user, done

      deleteSubscriptions user, =>
        obj = user.toObject()
        for prop, val of obj
          user.set(prop, undefined) unless prop is '_id'
        user.set('dateDeleted', new Date())
        user.set('deleted', true)

        # Hack to get saving of Users to work. Probably should replace these props with strings
        # so that validation doesn't get hung up on Date objects in the documents.
        delete obj.dateCreated

        user.save (err) =>
          return @sendDatabaseError(res, err) if err
          @sendNoContent res

  getByRelationship: (req, res, args...) ->
    return @agreeToCLA(req, res) if args[1] is 'agreeToCLA'
    return @agreeToEmployerAgreement(req, res) if args[1] is 'agreeToEmployerAgreement'
    return @avatar(req, res, args[0]) if args[1] is 'avatar'
    return @getByIDs(req, res) if args[1] is 'users'
    return @getNamesByIDs(req, res) if args[1] is 'names'
    return @getPrepaidCodes(req, res) if args[1] is 'prepaid_codes'
    return @getSchoolCounts(req, res) if args[1] is 'school_counts'
    return @nameToID(req, res, args[0]) if args[1] is 'nameToID'
    return @getLevelSessionsForEmployer(req, res, args[0]) if args[1] is 'level.sessions' and args[2] is 'employer'
    return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions'
    return @getCandidates(req, res) if args[1] is 'candidates'
    return @getClans(req, res, args[0]) if args[1] is 'clans'
    return @getCourseInstances(req, res, args[0]) if args[1] is 'course_instances'
    return @getEmployers(req, res) if args[1] is 'employers'
    return @getSimulatorLeaderboard(req, res, args[0]) if args[1] is 'simulatorLeaderboard'
    return @getMySimulatorLeaderboardRank(req, res, args[0]) if args[1] is 'simulator_leaderboard_rank'
    return @getEarnedAchievements(req, res, args[0]) if args[1] is 'achievements'
    return @getRecentlyPlayed(req, res, args[0]) if args[1] is 'recently_played'
    return @trackActivity(req, res, args[0], args[2], args[3]) if args[1] is 'track' and args[2]
    return @getRemark(req, res, args[0]) if args[1] is 'remark'
    return @searchForUser(req, res) if args[1] is 'admin_search'
    return @getStripeInfo(req, res, args[0]) if args[1] is 'stripe'
    return @getSubRecipients(req, res) if args[1] is 'sub_recipients'
    return @getSubSponsor(req, res) if args[1] is 'sub_sponsor'
    return @getSubSponsors(req, res) if args[1] is 'sub_sponsors'
    return @sendOneTimeEmail(req, res, args[0]) if args[1] is 'send_one_time_email'
    return @resetProgress(req, res, args[0]) if args[1] is 'reset_progress'
    return @sendNotFoundError(res)
    super(arguments...)

  getStripeInfo: (req, res, handle) ->
    @getDocumentForIdOrSlug handle, (err, user) =>
      return @sendNotFoundError(res) if not user
      return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or req.user.get('_id').equals(user.get('_id')))
      return @sendNotFoundError(res) if not customerID = user.get('stripe')?.customerID
      stripe.customers.retrieve customerID, (err, customer) =>
        return @sendDatabaseError(res, err) if err
        info = card: customer.sources?.data?[0]
        findStripeSubscription customerID, subscriptionID: user.get('stripe').subscriptionID, (err, subscription) =>
          info.subscription = subscription
          findStripeSubscription customerID, subscriptionID: user.get('stripe').sponsorSubscriptionID, (err, subscription) =>
            info.sponsorSubscription = subscription
            @sendSuccess(res, JSON.stringify(info, null, '\t'))

  getSubRecipients: (req, res) ->
    # Return map of userIDs to name/email/cancel date
    # TODO: Add test for this API

    return @sendSuccess(res, {}) if _.isEmpty(req.user?.get('stripe')?.recipients ? [])
    return @sendSuccess(res, {}) unless req.user.get('stripe')?.customerID?

    # Get recipients User info
    ids = (recipient.userID for recipient in req.user.get('stripe').recipients)
    User.find({'_id': { $in: ids} }, 'name emailLower').exec (err, users) =>
      info = {}
      _.each users, (user) -> info[user.id] = user.toObject()
      customerID = req.user.get('stripe').customerID

      nextBatch = (starting_after, done) ->
        options = limit: 100
        options.starting_after = starting_after if starting_after
        stripe.customers.listSubscriptions customerID, options, (err, subscriptions) ->
          return done(err) if err
          return done() unless subscriptions?.data?.length > 0
          for sub in subscriptions.data
            userID = sub.metadata?.id
            continue unless userID of info
            if sub.cancel_at_period_end and info[userID]['cancel_at_period_end'] isnt false
              info[userID]['cancel_at_period_end'] = new Date(sub.current_period_end * 1000)
            else
              info[userID]['cancel_at_period_end'] = false

          if subscriptions.has_more
            return nextBatch(subscriptions.data[subscriptions.data.length - 1].id, done)
          else
            return done()
      nextBatch null, (err) =>
        return @sendDatabaseError(res, err) if err
        @sendSuccess(res, info)

  getSubSponsor: (req, res) ->
    # TODO: Add test for this API

    return @sendSuccess(res, {}) unless req.user?.get('stripe')?.sponsorID?

    # Get sponsor User info
    User.findById req.user.get('stripe').sponsorID, (err, sponsor) =>
      return @sendDatabaseError(res, err) if err
      return @sendDatabaseError(res, 'No sponsor customerID') unless sponsor?.get('stripe')?.customerID?
      info =
        email: sponsor.get('emailLower')
        name: sponsor.get('name')

      # Get recipient subscription info
      findStripeSubscription sponsor.get('stripe')?.customerID, userID: req.user.id, (err, subscription) =>
        info.subscription = subscription
        @sendDatabaseError(res, 'No sponsored subscription found') unless info.subscription?
        @sendSuccess(res, info)

  getSubSponsors: (req, res) ->
    return @sendForbiddenError(res) unless req.user?.isAdmin()
    Payment.find {$where: 'this.purchaser && this.recipient && this.purchaser.valueOf() != this.recipient.valueOf()'}, (err, payments) =>
      return @sendDatabaseError(res, err) if err
      sponsorIDs = (payment.get('purchaser') for payment in payments)
      User.find {$and: [{_id: {$in: sponsorIDs}}, {"stripe.sponsorSubscriptionID": {$exists: true}}]}, (err, users) =>
        return @sendDatabaseError(res, err) if err
        sponsors = (@formatEntity(req, doc) for doc in users when doc.get('stripe').recipients?.length > 0)
        @sendSuccess(res, sponsors)

  sendOneTimeEmail: (req, res) ->
    # TODO: Should this API be somewhere else?
    # TODO: Where should email types be stored?
    # TODO: How do we schema validate an update db call?

    return @sendForbiddenError(res) unless req.user
    email = req.query.email or req.body.email
    type = req.query.type or req.body.type
    return @sendBadInputError res, 'No email given.' unless email?
    return @sendBadInputError res, 'No type given.' unless type?

    # log.warn "sendOneTimeEmail #{type} #{email}"

    unless type in ['subscribe modal parent', 'share progress modal parent']
      return @sendBadInputError res, "Unknown one-time email type #{type}"

    sendMail = (emailParams) =>
      sendwithus.api.send emailParams, (err, result) =>
        if err
          log.error "sendwithus one-time email error: #{err}, result: #{result}"
          return @sendError res, 500, 'send mail failed.'
        req.user.update {$push: {"emails.oneTimes": {type: type, email: email, sent: new Date()}}}, (err) =>
          return @sendDatabaseError(res, err) if err
          @sendSuccess(res, {result: 'success'})
          AnalyticsLogEvent.logEvent req.user, 'Sent one time email', email: email, type: type

    # Generic email data
    emailParams =
      recipient:
        address: email
      email_data:
        name: req.user.get('name') or ''
    if codeLanguage = req.user.get('aceConfig.language')
      codeLanguage = codeLanguage[0].toUpperCase() + codeLanguage.slice(1)
      codeLanguage = codeLanguage.replace 'script', 'Script'
      emailParams['email_data']['codeLanguage'] = codeLanguage
    if senderEmail = req.user.get('email')
      emailParams['email_data']['senderEmail'] = senderEmail

    # Type-specific email data
    if type is 'subscribe modal parent'
      emailParams['email_id'] = sendwithus.templates.parent_subscribe_email
    else if type is 'share progress modal parent'
      emailParams['email_id'] = sendwithus.templates.share_progress_email

    sendMail emailParams

  getPrepaidCodes: (req, res) ->
    return @sendSuccess(res, []) unless req.user?
    orQuery = [{ creator: req.user._id }, { 'redeemers.userID' :  req.user._id }]
    Prepaid.find({}).or(orQuery).exec (err, documents) =>
      @sendSuccess(res, documents)

  getSchoolCounts: (req, res) ->
    return @sendSuccess(res, []) unless req.user?.isAdmin()
    minCount = req.body.minCount ? 20
    query = {$and: [
        {anonymous: false},
        {schoolName: {$exists: true}},
        {schoolName: {$ne: ''}}
        ]}
    User.find(query, {schoolName: 1}).lean().exec (err, documents) =>
      return @sendDatabaseError(res, err) if err
      schoolCountMap = {}
      for doc in documents
        schoolName = doc.schoolName
        schoolCountMap[schoolName] ?= 0;
        schoolCountMap[schoolName]++;
      schoolCounts = []
      for schoolName, count of schoolCountMap
        continue unless count >= minCount
        schoolCounts.push schoolName: schoolName, count: count
      @sendSuccess(res, schoolCounts)
  agreeToCLA: (req, res) ->
    return @sendForbiddenError(res) unless req.user
    doc =
      user: req.user._id+''
      email: req.user.get 'email'
      name: req.user.get 'name'
      githubUsername: req.body.githubUsername
      created: new Date()+''
    collection = mongoose.connection.db.collection 'cla.submissions', (err, collection) =>
      return @sendDatabaseError(res, err) if err
      collection.insert doc, (err) =>
        return @sendDatabaseError(res, err) if err
        req.user.set('signedCLA', doc.created)
        req.user.save (err) =>
          return @sendDatabaseError(res, err) if err
          @sendSuccess(res, {result: 'success'})
          slack.sendSlackMessage "#{req.body.githubUsername or req.user.get('name')} just signed the CLA.", ['dev-feed']

  avatar: (req, res, id) ->
    if not isID(id)
      return @sendBadInputError(res, 'Invalid avatar id')
    @modelClass.findById(id).exec (err, document) =>
      return @sendDatabaseError(res, err) if err
      return @sendNotFoundError(res) unless document
      photoURL = document?.get('photoURL')
      if photoURL
        photoURL = "/file/#{photoURL}"
      else if req.query.employerPageAvatar is "true"
        photoURL = @buildGravatarURL document, req.query.s, "/images/pages/employer/anon_user.png"
      else
        photoURL = @buildGravatarURL document, req.query.s, req.query.fallback
      res.redirect photoURL
      res.end()

  getLevelSessionsForEmployer: (req, res, userID) ->
    return @sendForbiddenError(res) unless req.user
    return @sendForbiddenError(res) unless req.user._id+'' is userID or req.user.isAdmin() or ('employer' in (req.user.get('permissions') ? []))
    query = creator: userID, levelID: {$in: ['criss-cross', 'gridmancer', 'greed', 'dungeon-arena', 'brawlwood', 'gold-rush']}
    projection = 'levelName levelID team playtime codeLanguage submitted code totalScore teamSpells level'
    LevelSession.find(query).select(projection).exec (err, documents) =>
      return @sendDatabaseError(res, err) if err
      documents = (LevelSessionHandler.formatEntity(req, doc) for doc in documents)
      @sendSuccess(res, documents)

  IDify: (idOrSlug, done) ->
    return done null, idOrSlug if Handler.isID idOrSlug
    User.findBySlug idOrSlug, (err, user) -> done err, user?.get '_id'

  getLevelSessions: (req, res, userIDOrSlug) ->
    @IDify userIDOrSlug, (err, userID) =>
      return @sendDatabaseError res, err if err
      return @sendNotFoundError res unless userID?
      query = creator: userID + ''
      isAuthorized = req.user?._id+'' is userID or req.user?.isAdmin()
      projection = {}
      if req.query.project
        projection[field] = 1 for field in req.query.project.split(',') when isAuthorized or not (field in LevelSessionHandler.privateProperties)
      else unless isAuthorized
        projection[field] = 0 for field in LevelSessionHandler.privateProperties

      LevelSession.find(query).select(projection).exec (err, documents) =>
        return @sendDatabaseError(res, err) if err
        if req.query.order
          documents = _.sortBy documents, 'changed'
          if req.query.order + '' is '-1'
            documents.reverse()
        documents = (LevelSessionHandler.formatEntity(req, doc) for doc in documents)
        @sendSuccess(res, documents)

  getEarnedAchievements: (req, res, userIDOrSlug) ->
    @IDify userIDOrSlug, (err, userID) =>
      return @sendDatabaseError res, err if err
      return @sendNotFoundError res unless userID?
      query = user: userID + ''
      query.notified = false if req.query.notified is 'false'
      EarnedAchievement.find(query).sort(changed: -1).exec (err, documents) =>
        return @sendDatabaseError(res, err) if err?
        cleandocs = (@formatEntity(req, doc) for doc in documents)
        @sendSuccess(res, cleandocs)

  getRecentlyPlayed: (req, res, userID) ->
    twoWeeksAgo = moment().subtract('days', 14).toDate()
    LevelSession.find(creator: userID, changed: $gt: twoWeeksAgo).sort(changed: -1).exec (err, docs) =>
      return @sendDatabaseError res, err if err?
      cleandocs = (@formatEntity(req, doc) for doc in docs)
      @sendSuccess res, cleandocs

  trackActivity: (req, res, userID, activityName, increment=1) ->
    return @sendMethodNotAllowed res unless req.method is 'POST'
    isMe = userID is req.user?._id + ''
    isAuthorized = isMe or req.user?.isAdmin()
    isAuthorized ||= ('employer' in (req.user?.get('permissions') ? [])) and (activityName in ['viewed_by_employer', 'contacted_by_employer'])
    return @sendForbiddenError res unless isAuthorized
    updateUser = (user) =>
      activity = user.trackActivity activityName, increment
      user.update {activity: activity}, (err) =>
        return @sendDatabaseError res, err if err
        @sendSuccess res, result: 'success'
    if isMe
      updateUser(req.user)
    else
      @getDocumentForIdOrSlug userID, (err, user) =>
        return @sendDatabaseError res, err if err
        return @sendNotFoundError res unless user
        updateUser user

  agreeToEmployerAgreement: (req, res) ->
    userIsAnonymous = req.user?.get('anonymous')
    if userIsAnonymous then return errors.unauthorized(res, 'You need to be logged in to agree to the employer agreeement.')
    profileData = req.body
    #TODO: refactor this bit to make it more elegant
    if not profileData.id or not profileData.positions or not profileData.emailAddress or not profileData.firstName or not profileData.lastName
      return errors.badInput(res, 'You need to have a more complete profile to sign up for this service.')
    @modelClass.findById(req.user.id).exec (err, user) =>
      if user.get('employerAt') or user.get('signedEmployerAgreement') or 'employer' in (user.get('permissions') ? [])
        return errors.conflict(res, 'You already have signed the agreement!')
      #TODO: Search for the current position
      employerAt = _.filter(profileData.positions.values, 'isCurrent')[0]?.company.name ? 'Not available'
      signedEmployerAgreement =
        linkedinID: profileData.id
        date: new Date()
        data: profileData
      updateObject =
        'employerAt': employerAt
        'signedEmployerAgreement': signedEmployerAgreement
        $push: 'permissions': 'employer'

      User.update {'_id': req.user.id}, updateObject, (err, result) =>
        if err? then return errors.serverError(res, "There was an issue updating the user object to reflect employer status: #{err}")
        res.send({'message': 'The agreement was successful.'})
        res.end()

  getCandidates: (req, res) ->
    return @sendForbiddenError(res) unless req.user
    return @sendForbiddenError(res)  # No one can view the candidates, since in a rush, we deleted their index!
    authorized = req.user.isAdmin() or ('employer' in (req.user.get('permissions') ? []))
    months = if req.user.isAdmin() then 12 else 2
    since = (new Date((new Date()) - months * 30.4 * 86400 * 1000)).toISOString()
    query = {'jobProfile.updated': {$gt: since}}
    query['jobProfile.active'] = true unless req.user.isAdmin()
    selection = 'jobProfile jobProfileApproved photoURL'
    selection += ' email name' if authorized
    User.find(query).select(selection).exec (err, documents) =>
      return @sendDatabaseError(res, err) if err
      candidates = (candidate for candidate in documents when @employerCanViewCandidate req.user, candidate.toObject())
      candidates = (@formatCandidate(authorized, candidate) for candidate in candidates)
      @sendSuccess(res, candidates)

  getClans: (req, res, userIDOrSlug) ->
    @getDocumentForIdOrSlug userIDOrSlug, (err, user) =>
      return @sendNotFoundError(res) unless user
      clanIDs = user.get('clans') ? []
      query = {$and: [{_id: {$in: clanIDs}}]}
      query['$and'].push {type: 'public'} unless req.user?.id is user.id
      Clan.find query, (err, documents) =>
        return @sendDatabaseError(res, err) if err
        @sendSuccess(res, documents)

  getCourseInstances: (req, res, userIDOrSlug) ->
    @getDocumentForIdOrSlug userIDOrSlug, (err, user) =>
      return @sendNotFoundError(res) unless user
      CourseInstance.find {members: {$in: [user.get('_id')]}}, (err, documents) =>
        return @sendDatabaseError(res, err) if err
        @sendSuccess(res, documents)

  formatCandidate: (authorized, document) ->
    fields = if authorized then ['name', 'jobProfile', 'jobProfileApproved', 'photoURL', '_id'] else ['_id','jobProfile', 'jobProfileApproved']
    obj = _.pick document.toObject(), fields
    obj.photoURL ||= obj.jobProfile.photoURL #if authorized
    subfields = ['country', 'city', 'lookingFor', 'jobTitle', 'skills', 'experience', 'updated', 'active', 'shortDescription', 'curated', 'visa']
    if authorized
      subfields = subfields.concat ['name']
    obj.jobProfile = _.pick obj.jobProfile, subfields
    obj

  employerCanViewCandidate: (employer, candidate) ->
    return true if employer.isAdmin()
    for job in candidate.jobProfile?.work ? []
      # TODO: be smarter about different ways to write same company names to ensure privacy.
      # We'll have to manually pay attention to how we set employer names for now.
      if job.employer?.toLowerCase() is employer.get('employerAt')?.toLowerCase()
        log.info "#{employer.get('name')} at #{employer.get('employerAt')} can't see #{candidate.jobProfile.name} because s/he worked there."
      return false if job.employer?.toLowerCase() is employer.get('employerAt')?.toLowerCase()
    true

  getEmployers: (req, res) ->
    return @sendForbiddenError(res) unless req.user?.isAdmin()
    return @sendForbiddenError(res)  # No one can view the employers, since in a rush, we deleted their index!
    query = {employerAt: {$exists: true, $ne: ''}}
    selection = 'name firstName lastName email activity signedEmployerAgreement photoURL employerAt'
    User.find(query).select(selection).lean().exec (err, documents) =>
      return @sendDatabaseError res, err if err
      @sendSuccess res, documents

  buildGravatarURL: (user, size, fallback) ->
    emailHash = @buildEmailHash user
    fallback ?= 'https://codecombat.com/file/db/thang.type/52a00d55cf1818f2be00000b/portrait.png'
    fallback = "https://codecombat.com#{fallback}" unless /^http/.test fallback
    "https://www.gravatar.com/avatar/#{emailHash}?s=#{size}&default=#{fallback}"

  buildEmailHash: (user) ->
    # emailHash is used by gravatar
    hash = crypto.createHash('md5')
    if user.get('email')
      hash.update(_.trim(user.get('email')).toLowerCase())
    else
      hash.update(user.get('_id') + '')
    hash.digest('hex')

  getRemark: (req, res, userID) ->
    return @sendForbiddenError(res) unless req.user?.isAdmin()
    query = user: userID
    projection = null
    if req.query.project
      projection = {}
      projection[field] = 1 for field in req.query.project.split(',')
    UserRemark.findOne(query).select(projection).exec (err, remark) =>
      return @sendDatabaseError res, err if err
      return @sendNotFoundError res unless remark?
      @sendSuccess res, remark

  searchForUser: (req, res) ->
    return @sendForbiddenError(res) unless req.user?.isAdmin()
    return module.exports.mongoSearchForUser(req,res) unless config.sphinxServer
    mysql = require('mysql');
    connection = mysql.createConnection
      host: config.sphinxServer
      port: 9306
    connection.connect()

    q = req.body.search
    if isID q
      mysqlq = "SELECT *, WEIGHT() as skey FROM user WHERE mongoid = ? LIMIT 100;"
    else
      mysqlq = "SELECT *, WEIGHT() as skey FROM user WHERE MATCH(?)  LIMIT 100;"

    connection.query mysqlq, [q], (err, rows, fields) =>
      return @sendDatabaseError res, err if err
      ids = rows.map (r) -> r.mongoid
      User.find({_id: {$in: ids}}).select({name: 1, email: 1, dateCreated: 1}).lean().exec (err, users) =>
        return @sendDatabaseError res, err if err
        out = _.filter _.map ids, (id) => _.find(users, (u) -> String(u._id) is id)
        console.log(out)
        @sendSuccess res, out
    connection.end()


  mongoSearchForUser: (req, res) ->
    # TODO: also somehow search the CLAs to find a match amongst those fields and to find GitHub ids
    return @sendForbiddenError(res) unless req.user?.isAdmin()
    search = req.body.search
    query = email: {$exists: true}, $or: [
      {emailLower: search}
      {nameLower: search}
    ]
    query.$or.push {_id: mongoose.Types.ObjectId(search) if isID search}
    if search.length > 5
      searchParts = search.split(/[.+@]/)
      if searchParts.length > 1
        query.$or.push {emailLower: {$regex: '^' + searchParts[0]}}
    projection = name: 1, email: 1, dateCreated: 1
    User.find(query).select(projection).lean().exec (err, users) =>
      return @sendDatabaseError res, err if err
      @sendSuccess res, users

  resetProgress: (req, res, userID) ->
    return @sendMethodNotAllowed res unless req.method is 'POST'
    return @sendForbiddenError res unless userID and userID is req.user?._id + ''  # Only you can reset your own progress
    return @sendForbiddenError res if req.user?.isAdmin()  # Protect admins from resetting their progress
    @constructor.resetProgressForUser req.user, (err, results) =>
      return @sendDatabaseError res, err if err
      @sendSuccess res, result: 'success'

  @resetProgressForUser: (user, cb) ->
    async.parallel [
      (cb) -> LevelSession.remove {creator: user._id + ''}, cb
      (cb) -> EarnedAchievement.remove {user: user._id + ''}, cb
      (cb) -> UserPollsRecord.remove {user: user._id + ''}, cb
      (cb) -> user.update {points: 0, 'stats.gamesCompleted': 0, 'stats.concepts': {}, 'earned.gems': 0, 'earned.levels': [], 'earned.items': [], 'earned.heroes': [], 'purchased.items': [], 'purchased.heroes': [], spent: 0}, cb
    ], cb

  countEdits = (model, done) ->
    statKey = User.statsMapping.edits[model.modelName]
    return done(new Error 'Could not resolve statKey for model') unless statKey?
    userStream = User.find({anonymous: false}).sort('_id').stream()
    streamFinished = false
    usersTotal = 0
    usersFinished = 0
    numberRunning = 0
    doneWithUser = (err) ->
      log.error err if err?
      ++usersFinished
      --numberRunning
      userStream.resume()
      done?() if streamFinished and usersFinished is usersTotal
    userStream.on 'error', (err) -> log.error err
    userStream.on 'close', -> streamFinished = true
    userStream.on 'data',  (user) ->
      ++usersTotal
      ++numberRunning
      userStream.pause() if numberRunning > 20
      userObjectID = user.get('_id')
      userStringID = userObjectID.toHexString()

      model.count {$or: [creator: userObjectID, creator: userStringID]}, (err, count) ->
        if count
          update = $set: {}
          update.$set[statKey] = count
        else
          update = $unset: {}
          update.$unset[statKey] = ''
        log.info "... updating #{userStringID} patches #{statKey} to #{count}, #{usersTotal} players found so far." if count
        User.findByIdAndUpdate user.get('_id'), update, (err) ->
          log.error err if err?
          doneWithUser()

  # I don't like leaking big variables, could remove this for readability
  # Meant for passing into MongoDB
  {isMiscPatch, isTranslationPatch} = do ->
    deltas = require '../../app/core/deltas'

    isMiscPatch: (obj) ->
      expanded = deltas.flattenDelta obj.get 'delta'
      _.some expanded, (delta) -> 'i18n' not in delta.dataPath
    isTranslationPatch: (obj) ->
      expanded = deltas.flattenDelta obj.get 'delta'
      _.some expanded, (delta) -> 'i18n' in delta.dataPath

  Patch = require '../models/Patch'
  # filter is passed a mongoose document and should return a boolean,
  # determining whether the patch should be counted
  countPatchesByUsersInMemory = (query, filter, statName, done) ->
    updateUser = (user, count, doneUpdatingUser) ->
      method = if count then '$set' else '$unset'
      update = {}
      update[method] = {}
      update[method][statName] = count or ''
      log.info "... updating #{user.get('_id')} patches #{JSON.stringify(query)} #{statName} to #{count}, #{usersTotal} players found so far." if count
      User.findByIdAndUpdate user.get('_id'), update, doneUpdatingUser

    userStream = User.find({anonymous: false}).sort('_id').stream()
    streamFinished = false
    usersTotal = 0
    usersFinished = 0
    numberRunning = 0
    doneWithUser = (err) ->
      log.error err if err?
      ++usersFinished
      --numberRunning
      userStream.resume()
      done?() if streamFinished and usersFinished is usersTotal
    userStream.on 'error', (err) -> log.error err
    userStream.on 'close', -> streamFinished = true
    userStream.on 'data',  (user) ->
      ++usersTotal
      ++numberRunning
      userStream.pause() if numberRunning > 20
      userObjectID = user.get '_id'
      userStringID = userObjectID.toHexString()
      # Extend query with a patch ownership test
      _.extend query, {$or: [{creator: userObjectID}, {creator: userStringID}]}

      count = 0
      stream = Patch.where(query).stream()
      stream.on 'data', (doc) -> ++count if filter doc
      stream.on 'error', (err) ->
        updateUser user, count, doneWithUser
        log.error "Recalculating #{statName} for user #{user} stopped prematurely because of error"
      stream.on 'close', ->
        updateUser user, count, doneWithUser

  countPatchesByUsers = (query, statName, done) ->
    Patch = require '../models/Patch'

    userStream = User.find({anonymous: false}).sort('_id').stream()
    streamFinished = false
    usersTotal = 0
    usersFinished = 0
    numberRunning = 0
    doneWithUser = (err) ->
      log.error err if err?
      ++usersFinished
      --numberRunning
      userStream.resume()
      done?() if streamFinished and usersFinished is usersTotal
    userStream.on 'error', (err) -> log.error err
    userStream.on 'close', -> streamFinished = true
    userStream.on 'data',  (user) ->
      ++usersTotal
      ++numberRunning
      userStream.pause() if numberRunning > 20
      userObjectID = user.get '_id'
      userStringID = userObjectID.toHexString()
      # Extend query with a patch ownership test
      _.extend query, {$or: [{creator: userObjectID}, {creator: userStringID}]}

      Patch.count query, (err, count) ->
        method = if count then '$set' else '$unset'
        update = {}
        update[method] = {}
        update[method][statName] = count or ''
        log.info "... updating #{userStringID} patches #{query} to #{count}, #{usersTotal} players found so far." if count
        User.findByIdAndUpdate user.get('_id'), update, doneWithUser

  statRecalculators:
    gamesCompleted: (done) ->
      LevelSession = require '../models/LevelSession'

      userStream = User.find({anonymous: false}).sort('_id').stream()
      streamFinished = false
      usersTotal = 0
      usersFinished = 0
      numberRunning = 0
      doneWithUser = (err) ->
        log.error err if err?
        ++usersFinished
        --numberRunning
        userStream.resume()
        if streamFinished and usersFinished is usersTotal
          log.info "----------- Finished recalculating statistics for gamesCompleted for #{usersFinished} players. -----------"
          done?()
      userStream.on 'error', (err) -> log.error err
      userStream.on 'close', -> streamFinished = true
      userStream.on 'data',  (user) ->
        ++usersTotal
        ++numberRunning
        userStream.pause() if numberRunning > 20
        userID = user.get('_id').toHexString()

        LevelSession.count {creator: userID, 'state.complete': true}, (err, count) ->
          update = if count then {$set: 'stats.gamesCompleted': count} else {$unset: 'stats.gamesCompleted': ''}
          log.info "... updating #{userID} gamesCompleted to #{count}, #{usersTotal} players found so far." if Math.random() < 0.001
          User.findByIdAndUpdate user.get('_id'), update, doneWithUser

    articleEdits: (done) ->
      Article = require '../models/Article'
      countEdits Article, done

    levelEdits: (done) ->
      Level = require '../models/Level'
      countEdits Level, done

    levelComponentEdits: (done) ->
      LevelComponent = require '../models/LevelComponent'
      countEdits LevelComponent,  done

    levelSystemEdits: (done) ->
      LevelSystem = require '../models/LevelSystem'
      countEdits LevelSystem, done

    thangTypeEdits: (done) ->
      ThangType = require '../models/ThangType'
      countEdits ThangType, done

    patchesContributed: (done) ->
      countPatchesByUsers {'status': 'accepted'}, 'stats.patchesContributed', done

    patchesSubmitted: (done) ->
      countPatchesByUsers {}, 'stats.patchesSubmitted', done

    # The below need functions for filtering and are thus checked in memory
    totalTranslationPatches: (done) ->
      countPatchesByUsersInMemory {}, isTranslationPatch, 'stats.totalTranslationPatches', done

    totalMiscPatches: (done) ->
      countPatchesByUsersInMemory {}, isMiscPatch, 'stats.totalMiscPatches', done

    articleMiscPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'article'}, isMiscPatch, User.statsMapping.misc.article, done

    levelMiscPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'level'}, isMiscPatch, User.statsMapping.misc.level, done

    levelComponentMiscPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'level_component'}, isMiscPatch, User.statsMapping.misc['level.component'], done

    levelSystemMiscPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'level_system'}, isMiscPatch, User.statsMapping.misc['level.system'], done

    thangTypeMiscPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'thang_type'}, isMiscPatch, User.statsMapping.misc['thang.type'], done

    articleTranslationPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'article'}, isTranslationPatch, User.statsMapping.translations.article, done

    levelTranslationPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'level'}, isTranslationPatch, User.statsMapping.translations.level, done

    levelComponentTranslationPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'level_component'}, isTranslationPatch, User.statsMapping.translations['level.component'], done

    levelSystemTranslationPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'level_system'}, isTranslationPatch, User.statsMapping.translations['level.system'], done

    thangTypeTranslationPatches: (done) ->
      countPatchesByUsersInMemory {'target.collection': 'thang_type'}, isTranslationPatch, User.statsMapping.translations['thang.type'], done


  recalculateStats: (statName, done) =>
    done new Error 'Recalculation handler not found' unless statName of @statRecalculators
    @statRecalculators[statName] done

  recalculate: (req, res, statName) ->
    return @sendForbiddenError(res) unless req.user?.isAdmin()
    log.debug 'recalculate'
    return @sendNotFoundError(res) unless statName of @statRecalculators
    @recalculateStats statName
    @sendAccepted res, {}

module.exports = new UserHandler()
