<commit_before>package lila.setup

import akka.stream.scaladsl._
import chess.variant.Variant
import chess.{ Clock, Mode }
import org.joda.time.DateTime
import play.api.data._
import play.api.data.Forms._
import play.api.libs.json.Json
import scala.concurrent.duration._

import lila.common.{ Bearer, Template }
import lila.game.{ Game, IdGenerator }
import lila.oauth.{ AccessToken, OAuthScope, OAuthServer }
import lila.user.User

object SetupBulk {

  val maxGames = 500

  case class BulkFormData(
      tokens: String,
      variant: Variant,
      clock: Clock.Config,
      rated: Boolean,
      pairAt: Option[DateTime],
      startClocksAt: Option[DateTime],
      message: Option[Template]
  )

  private def timestampInNearFuture = longNumber(
    min = 0,
    max = DateTime.now.plusDays(1).getMillis
  )

  def form = Form[BulkFormData](
    mapping(
      "players" -> nonEmptyText
        .verifying("Not enough tokens", t => extractTokenPairs(t).nonEmpty)
        .verifying(s"Too many tokens (max: ${maxGames * 2})", t => extractTokenPairs(t).sizeIs < maxGames)
        .verifying(
          "Tokens must be unique",
          t => {
            val tokens = extractTokenPairs(t).view.flatMap { case (w, b) => Vector(w, b) }.toVector
            tokens.size == tokens.distinct.size
          }
        ),
      SetupForm.api.variant,
      "clock"         -> SetupForm.api.clockMapping,
      "rated"         -> boolean,
      "pairAt"        -> optional(timestampInNearFuture),
      "startClocksAt" -> optional(timestampInNearFuture),
      "message"       -> SetupForm.api.message
    ) {
      (
          tokens: String,
          variant: Option[String],
          clock: Clock.Config,
          rated: Boolean,
          pairTs: Option[Long],
          clockTs: Option[Long],
          message: Option[String]
      ) =>
        BulkFormData(
          tokens,
          Variant orDefault ~variant,
          clock,
          rated,
          pairTs.map { new DateTime(_) },
          clockTs.map { new DateTime(_) },
          message map Template
        )
    }(_ => None)
  )

  private[setup] def extractTokenPairs(str: String): List[(Bearer, Bearer)] =
    str
      .split(',')
      .view
      .map(_ split ":")
      .collect { case Array(w, b) =>
        w.trim -> b.trim
      }
      .collect {
        case (w, b) if w.nonEmpty && b.nonEmpty => (Bearer(w), Bearer(b))
      }
      .toList

  case class BadToken(token: Bearer, error: OAuthServer.AuthError)

  case class ScheduledGame(id: Game.ID, white: User.ID, black: User.ID)

  case class ScheduledBulk(
      _id: String,
      by: User.ID,
      games: List[ScheduledGame],
      variant: Variant,
      clock: Clock.Config,
      mode: Mode,
      pairAt: DateTime,
      startClocksAt: Option[DateTime],
      scheduledAt: DateTime,
      message: Option[Template],
      pairedAt: Option[DateTime] = None
  ) {
    def userSet = Set(games.flatMap(g => List(g.white, g.black)))
    def collidesWith(other: ScheduledBulk) = {
      pairAt == other.pairAt || startClocksAt == other.startClocksAt
    } && userSet.exists(other.userSet.contains)
  }

  sealed trait ScheduleError
  case class BadTokens(tokens: List[BadToken])    extends ScheduleError
  case class DuplicateUsers(users: List[User.ID]) extends ScheduleError
  case object RateLimited                         extends ScheduleError

  def toJson(bulk: ScheduledBulk) = {
    import bulk._
    import lila.common.Json.jodaWrites
    Json
      .obj(
        "id" -> _id,
        "games" -> games.map { g =>
          Json.obj(
            "id"    -> g.id,
            "white" -> g.white,
            "black" -> g.black
          )
        },
        "variant" -> variant.key,
        "clock" -> Json.obj(
          "limit"     -> clock.limitSeconds,
          "increment" -> clock.incrementSeconds
        ),
        "rated"         -> mode.rated,
        "pairAt"        -> pairAt,
        "startClocksAt" -> startClocksAt,
        "scheduledAt"   -> scheduledAt,
        "pairedAt"      -> pairedAt
      )
      .add("message" -> message.map(_.value))
  }

}

final class SetupBulkApi(oauthServer: OAuthServer, idGenerator: IdGenerator)(implicit
    ec: scala.concurrent.ExecutionContext,
    mat: akka.stream.Materializer
) {

  import SetupBulk._

  type Result = Either[ScheduleError, ScheduledBulk]

  private val rateLimit = new lila.memo.RateLimit[User.ID](
    credits = maxGames,
    duration = 10.minutes,
    key = "challenge.bulk"
  )

  def apply(data: BulkFormData, me: User): Fu[Result] =
    Source(extractTokenPairs(data.tokens))
      .mapConcat { case (whiteToken, blackToken) =>
        List(whiteToken, blackToken) // flatten now, re-pair later!
      }
      .mapAsync(8) { token =>
        oauthServer.auth(token, List(OAuthScope.Challenge.Write)) map {
          _.left.map { BadToken(token, _) }
        }
      }
      .runFold[Either[List[BadToken], List[User.ID]]](Right(Nil)) {
        case (Left(bads), Left(bad))       => Left(bad :: bads)
        case (Left(bads), _)               => Left(bads)
        case (Right(_), Left(bad))         => Left(bad :: Nil)
        case (Right(users), Right(scoped)) => Right(scoped.user.id :: users)
      }
      .flatMap {
        case Left(errors) => fuccess(Left(BadTokens(errors.reverse)))
        case Right(allPlayers) =>
          val dups = allPlayers
            .groupBy(identity)
            .view
            .mapValues(_.size)
            .collect {
              case (u, nb) if nb > 1 => u
            }
            .toList
          if (dups.nonEmpty) fuccess(Left(DuplicateUsers(dups)))
          else {
            val pairs = allPlayers.reverse
              .grouped(2)
              .collect { case List(w, b) => (w, b) }
              .toList
            val nbGames = pairs.size
            rateLimit[Fu[Result]](me.id, cost = nbGames) {
              lila.mon.api.challenge.bulk.scheduleNb(me.id).increment(nbGames).unit
              idGenerator
                .games(nbGames)
                .map {
                  _.toList zip pairs
                }
                .map {
                  _.map { case (id, (w, b)) =><commit_msg>let verified accounts create up to 1500 games in bulk<commit_after>package lila.setup

import akka.stream.scaladsl._
import chess.variant.Variant
import chess.{ Clock, Mode }
import org.joda.time.DateTime
import play.api.data._
import play.api.data.Forms._
import play.api.libs.json.Json
import scala.concurrent.duration._

import lila.common.{ Bearer, Template }
import lila.game.{ Game, IdGenerator }
import lila.oauth.{ AccessToken, OAuthScope, OAuthServer }
import lila.user.User

object SetupBulk {

  val maxGames = 500

  case class BulkFormData(
      tokens: String,
      variant: Variant,
      clock: Clock.Config,
      rated: Boolean,
      pairAt: Option[DateTime],
      startClocksAt: Option[DateTime],
      message: Option[Template]
  )

  private def timestampInNearFuture = longNumber(
    min = 0,
    max = DateTime.now.plusDays(1).getMillis
  )

  def form = Form[BulkFormData](
    mapping(
      "players" -> nonEmptyText
        .verifying("Not enough tokens", t => extractTokenPairs(t).nonEmpty)
        .verifying(s"Too many tokens (max: ${maxGames * 2})", t => extractTokenPairs(t).sizeIs < maxGames)
        .verifying(
          "Tokens must be unique",
          t => {
            val tokens = extractTokenPairs(t).view.flatMap { case (w, b) => Vector(w, b) }.toVector
            tokens.size == tokens.distinct.size
          }
        ),
      SetupForm.api.variant,
      "clock"         -> SetupForm.api.clockMapping,
      "rated"         -> boolean,
      "pairAt"        -> optional(timestampInNearFuture),
      "startClocksAt" -> optional(timestampInNearFuture),
      "message"       -> SetupForm.api.message
    ) {
      (
          tokens: String,
          variant: Option[String],
          clock: Clock.Config,
          rated: Boolean,
          pairTs: Option[Long],
          clockTs: Option[Long],
          message: Option[String]
      ) =>
        BulkFormData(
          tokens,
          Variant orDefault ~variant,
          clock,
          rated,
          pairTs.map { new DateTime(_) },
          clockTs.map { new DateTime(_) },
          message map Template
        )
    }(_ => None)
  )

  private[setup] def extractTokenPairs(str: String): List[(Bearer, Bearer)] =
    str
      .split(',')
      .view
      .map(_ split ":")
      .collect { case Array(w, b) =>
        w.trim -> b.trim
      }
      .collect {
        case (w, b) if w.nonEmpty && b.nonEmpty => (Bearer(w), Bearer(b))
      }
      .toList

  case class BadToken(token: Bearer, error: OAuthServer.AuthError)

  case class ScheduledGame(id: Game.ID, white: User.ID, black: User.ID)

  case class ScheduledBulk(
      _id: String,
      by: User.ID,
      games: List[ScheduledGame],
      variant: Variant,
      clock: Clock.Config,
      mode: Mode,
      pairAt: DateTime,
      startClocksAt: Option[DateTime],
      scheduledAt: DateTime,
      message: Option[Template],
      pairedAt: Option[DateTime] = None
  ) {
    def userSet = Set(games.flatMap(g => List(g.white, g.black)))
    def collidesWith(other: ScheduledBulk) = {
      pairAt == other.pairAt || startClocksAt == other.startClocksAt
    } && userSet.exists(other.userSet.contains)
  }

  sealed trait ScheduleError
  case class BadTokens(tokens: List[BadToken])    extends ScheduleError
  case class DuplicateUsers(users: List[User.ID]) extends ScheduleError
  case object RateLimited                         extends ScheduleError

  def toJson(bulk: ScheduledBulk) = {
    import bulk._
    import lila.common.Json.jodaWrites
    Json
      .obj(
        "id" -> _id,
        "games" -> games.map { g =>
          Json.obj(
            "id"    -> g.id,
            "white" -> g.white,
            "black" -> g.black
          )
        },
        "variant" -> variant.key,
        "clock" -> Json.obj(
          "limit"     -> clock.limitSeconds,
          "increment" -> clock.incrementSeconds
        ),
        "rated"         -> mode.rated,
        "pairAt"        -> pairAt,
        "startClocksAt" -> startClocksAt,
        "scheduledAt"   -> scheduledAt,
        "pairedAt"      -> pairedAt
      )
      .add("message" -> message.map(_.value))
  }

}

final class SetupBulkApi(oauthServer: OAuthServer, idGenerator: IdGenerator)(implicit
    ec: scala.concurrent.ExecutionContext,
    mat: akka.stream.Materializer
) {

  import SetupBulk._

  type Result = Either[ScheduleError, ScheduledBulk]

  private val rateLimit = new lila.memo.RateLimit[User.ID](
    credits = maxGames * 3,
    duration = 10.minutes,
    key = "challenge.bulk"
  )

  def apply(data: BulkFormData, me: User): Fu[Result] =
    Source(extractTokenPairs(data.tokens))
      .mapConcat { case (whiteToken, blackToken) =>
        List(whiteToken, blackToken) // flatten now, re-pair later!
      }
      .mapAsync(8) { token =>
        oauthServer.auth(token, List(OAuthScope.Challenge.Write)) map {
          _.left.map { BadToken(token, _) }
        }
      }
      .runFold[Either[List[BadToken], List[User.ID]]](Right(Nil)) {
        case (Left(bads), Left(bad))       => Left(bad :: bads)
        case (Left(bads), _)               => Left(bads)
        case (Right(_), Left(bad))         => Left(bad :: Nil)
        case (Right(users), Right(scoped)) => Right(scoped.user.id :: users)
      }
      .flatMap {
        case Left(errors) => fuccess(Left(BadTokens(errors.reverse)))
        case Right(allPlayers) =>
          val dups = allPlayers
            .groupBy(identity)
            .view
            .mapValues(_.size)
            .collect {
              case (u, nb) if nb > 1 => u
            }
            .toList
          if (dups.nonEmpty) fuccess(Left(DuplicateUsers(dups)))
          else {
            val pairs = allPlayers.reverse
              .grouped(2)
              .collect { case List(w, b) => (w, b) }
              .toList
            val nbGames = pairs.size
            val cost    = nbGames * (if (me.isVerified) 1 else 3)
            rateLimit[Fu[Result]](me.id, cost = nbGames) {
              lila.mon.api.challenge.bulk.scheduleNb(me.id).increment(nbGames).unit
              idGenerator
                .games(nbGames)
                .map {
                  _.toList zip pairs
                }
                .map {
                  _.map { case (id, (w, b)) =>
{'fraction_non_alphanumeric': 0.12393915132105685, 'fraction_numerical': 0.002321857485988791, 'mean_word_length': 1.974047619047619, 'pattern_counts': {'":': 2, '<': 5, '<?xml version=': 0, '>': 91, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '1870559', 'n_tokens_mistral': 3971, 'n_tokens_neox': 3851, 'n_words': 840}