from flask import Blueprint, render_template, request, redirect, url_for, flash, make_response, jsonify, after_this_request, Response
from app.util import auth, websiteFeatures
from app.extensions import db, redis_controller, csrf, get_remote_address
import uuid
import requests
import time
from config import Config
import json
from datetime import datetime, timedelta
import secrets
import random
import string
import logging
import base64
import hashlib

from app.models.user import User
from app.models.gameservers import GameServer
from app.models.placeservers import PlaceServer
from app.models.placeserver_players import PlaceServerPlayer
from app.models.place import Place
from app.models.asset import Asset
from app.models.login_records import LoginRecord
from app.models.user_hwid_log import UserHWIDLog
from app.models.user_ban import UserBan
from app.models.admin_permissions import AdminPermissions
from app.models.universe import Universe
from app.models.asset_version import AssetVersion
from app.enums.AssetType import AssetType
from app.enums.BanType import BanType
from app.enums.MembershipType import MembershipType
from app.enums.PlaceYear import PlaceYear
from app.services.gameserver_comm import perform_post
from app.util.membership import GetUserMembership
from app.util.signscript import signUTF8
from app.util.assetversion import GetLatestAssetVersion
from app.routes.jobreporthandler import EvictPlayer
from app.routes.asset import GenerateTempAuthToken

config = Config()

class PlaceServerCooldownStart( Exception ):
    pass
class NoAvailableGameServers( Exception ):
    pass
class MissingData( Exception ):
    pass
class UnsupportedPlaceYear( Exception ):
    pass
class UnexpectedStatusCode( Exception ):
    pass
class BadResponseData( Exception ):
    pass
def CreateNewPlaceServer( placeId : int, reserved_server_access_code : str = None ) -> PlaceServer:
    """
        Starts a new PlaceServer for the given placeId, raises appropriate exceptions if it fails

        :param placeId: The placeId to start a new PlaceServer for
        :reserved_server_access_code: The reserved server access code to use, if None it will be a public server

        :return: PlaceServer object if successful
    """
    CooldownKeyName : str = f"create_new_place_server:{placeId}:{reserved_server_access_code}"
    if redis_controller.get(CooldownKeyName) is not None:
        logging.debug(f"CreateNewPlaceServer -> Place {placeId} recently requested to create a new place server, skipping")
        raise PlaceServerCooldownStart("")
    redis_controller.setex(CooldownKeyName, 40, "1")

    SelectedGameServerObj : GameServer = GameServer.query.filter_by(
        allowGameServerHost = True
    ).filter(
        GameServer.lastHeartbeat > datetime.utcnow() - timedelta(seconds=30)
    ).order_by(
        GameServer.RCCmemoryUsage.asc()
    ).first()

    if SelectedGameServerObj is None:
        logging.error(f"CreateNewPlaceServer -> Failed to find a available Gameserver to host Place {placeId}")
        raise NoAvailableGameServers("")
    
    PlaceObj : Place = Place.query.filter_by( placeid = placeId ).first()
    AssetObj : Asset = Asset.query.filter_by( id = placeId ).first()
    
    if PlaceObj is None or AssetObj is None:
        logging.error(f"CreateNewPlaceServer -> Failed to find Place / Asset {placeId} in database")
        raise MissingData("")
    
    UniverseObj : Universe = Universe.query.filter_by( id = PlaceObj.parent_universe_id ).first()
    if UniverseObj is None:
        logging.error(f"CreateNewPlaceServer -> Failed to find Universe {PlaceObj.parent_universe_id} in database for Place {placeId}")
        raise MissingData("")
    
    logging.info(f"CreateNewPlaceServer -> Attempting to start new place server for Place {PlaceObj.placeid}, Universe {UniverseObj.id} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), reserved_server_access_code: {reserved_server_access_code}")

    RequestSession = requests.Session()
    RequestSession.headers.update({
        "Authorization": SelectedGameServerObj.accessKey
    })

    JSONOpenPayload = {}
    GameOpenRoute = "Game"
    JobId : str = str( uuid.uuid4() )
    CommApiKey : str = str( uuid.uuid4() )

    LatestPlaceVersion : AssetVersion = GetLatestAssetVersion( AssetObj )

    if UniverseObj.place_year == PlaceYear.Sixteen:
        TempPlaceAuthorizationToken : str = GenerateTempAuthToken( placeId, Expiration = 200, CreatorIP = None )
        JSONOpenPayload = {
            "placeid": placeId,
            "creatorId": AssetObj.creator_id,
            "creatorType": AssetObj.creator_type,
            "SpecialAccessToken": TempPlaceAuthorizationToken,
            "useNewLoadFile": False,
            "loadfile_location": f"{config.BaseURL}/game/gameserver2016.lua",
            "universeid": UniverseObj.id,
            "place_version": LatestPlaceVersion.version
        }
    elif UniverseObj.place_year == PlaceYear.Eighteen:
        GameOpenRoute = "Game2018"
        JSONOpenPayload = {
            "placeid": placeId,
            "creatorId": AssetObj.creator_id,
            "creatorType": "User" if AssetObj.creator_type == 0 else "Group",
            "jobid": JobId,
            "apikey": CommApiKey,
            "maxplayers": PlaceObj.maxplayers,
            "address": SelectedGameServerObj.serverIP,
            "universeid": UniverseObj.id,
            "place_version": LatestPlaceVersion.version
        }
    elif UniverseObj.place_year == PlaceYear.Twenty:
        GameOpenRoute = "Game2020"
        JSONOpenPayload = {
            "placeid": placeId,
            "creatorId": AssetObj.creator_id,
            "creatorType": "User" if AssetObj.creator_type == 0 else "Group",
            "jobid": JobId,
            "apikey": CommApiKey,
            "maxplayers": PlaceObj.maxplayers,
            "address": SelectedGameServerObj.serverIP,
            "universeid": UniverseObj.id,
            "place_version": LatestPlaceVersion.version
        }
    elif UniverseObj.place_year == PlaceYear.Fourteen:
        GameOpenRoute = "Game2014"
        JSONOpenPayload = {
            "placeid": placeId,
            "creatorId": AssetObj.creator_id,
            "creatorType": AssetObj.creator_type,
            "universeid": UniverseObj.id,
            "place_version": LatestPlaceVersion.version
        }
    elif UniverseObj.place_year == PlaceYear.TwentyOne:
        GameOpenRoute = "Game2021"
        JSONOpenPayload = {
            "placeid": placeId,
            "creatorId": AssetObj.creator_id,
            "creatorType": "User" if AssetObj.creator_type == 0 else "Group",
            "jobid": JobId,
            "apikey": CommApiKey,
            "maxplayers": PlaceObj.maxplayers,
            "address": SelectedGameServerObj.serverIP,
            "universeid": UniverseObj.id,
            "place_version": LatestPlaceVersion.version
        }
    else:
        logging.error(f"CreateNewPlaceServer -> Failed to start new place server for Place {placeId}, unsupported place year got {UniverseObj.place_year.name}")
        raise UnsupportedPlaceYear(f"Year {UniverseObj.place_year.name} is not supported")
    
    if UniverseObj.place_year in [ PlaceYear.Eighteen, PlaceYear.Twenty, PlaceYear.TwentyOne ]:
        redis_controller.set(f"GameServerAccessKey:{CommApiKey}:{JobId}", "1", ex=60*60*24*2)
    if UniverseObj.place_year in [ PlaceYear.Fourteen ]:
        redis_controller.set(f"gameserver2014lua:{PlaceObj.placeid}:{JobId}", "1", ex=120)

    try:
        OpenJobReq = perform_post(
            TargetGameserver = SelectedGameServerObj,
            Endpoint = GameOpenRoute,
            JSONData = JSONOpenPayload,
            RequestTimeout = 35
        )
    except requests.exceptions.Timeout:
        logging.error(f"CreateNewPlaceServer -> Failed to start new place server for Place {placeId} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), open request timed out")
        raise NoAvailableGameServers(f"Request timed out")
    
    if OpenJobReq.status_code != 200:
        logging.error(f"CreateNewPlaceServer -> Failed to start new place server for Place {placeId} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), response: {OpenJobReq.content}")
        raise UnexpectedStatusCode(f"Got status code {OpenJobReq.status_code} from Gameserver")
    
    OpenJobReqJSON = OpenJobReq.json()
    if "jobid" not in OpenJobReqJSON or "port" not in OpenJobReqJSON:
        logging.error(f"CreateNewPlaceServer -> Failed to start new place server for Place {placeId} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), missing 'port' or 'jobid' in JSON,response: {OpenJobReqJSON}")
        raise BadResponseData(f"Missing 'port' or 'jobid' in JSON response")
    redis_controller.set(f"place:{OpenJobReqJSON['jobid']}:origin", str(SelectedGameServerObj.serverId), ex=60*60*24*2)
    PlaceServerObject = PlaceServer(
        serveruuid = OpenJobReqJSON["jobid"],
        originServerId = SelectedGameServerObj.serverId,
        serverIP = SelectedGameServerObj.serverIP,
        serverPort = OpenJobReqJSON["port"],
        serverPlaceId = placeId,
        maxPlayerCount = PlaceObj.maxplayers,
        reservedServerAccessCode = reserved_server_access_code
    )

    db.session.add(PlaceServerObject)
    db.session.commit()
    logging.info(f"CreateNewPlaceServer -> Started new place server for Place {placeId}, Universe {UniverseObj.id} on Gameserver {SelectedGameServerObj.serverName} ({SelectedGameServerObj.serverId}), is_reserved: {reserved_server_access_code is not None}")

    return PlaceServerObject

def GetSuitablePlaceServer( placeId : int ) -> PlaceServer | bool:
    """
        Returns a suitable PlaceServer for the given placeId

        :param placeId: The placeId to find a suitable PlaceServer for

        :return: PlaceServer object if successful, returns False if no PlaceServer is found
    """
    PlaceObj : Place = Place.query.filter_by( placeid = placeId ).first()
    if PlaceObj is None:
        logging.error(f"GetSuitablePlaceServer -> Failed to find Place {placeId} in database")
        raise MissingData("")
    
    HasFoundSuitablePlaceServer : bool = False

    PlaceServers = PlaceServer.query.filter_by( serverPlaceId = placeId, reservedServerAccessCode = None ).all()
    if PlaceServers is None or ( type(PlaceServers) == list and len(PlaceServers) == 0 ):
        HasFoundSuitablePlaceServer = False
    
    if not HasFoundSuitablePlaceServer:
        for PlaceServerObject in PlaceServers:
            PlaceServerObject : PlaceServer
            if PlaceServerObject.maxPlayerCount <= PlaceServerObject.playerCount:
                continue
            if PlaceServerObject.serverRunningTime == 0:
                continue
            return PlaceServerObject

    if HasFoundSuitablePlaceServer is False:
        logging.info(f"GetSuitablePlaceServer -> Place {placeId} has no PlaceServers, attempting to start one")
        try:
            NewPlaceServerObj = CreateNewPlaceServer( placeId = placeId, reserved_server_access_code = None )
        except:
            return False
        
        return False

GameJoinRoute = Blueprint('gamejoin', __name__, url_prefix='/')

@GameJoinRoute.route('/universes/validate-place-join', methods=['GET'])
def validateplacejoin():
    return "true"

@GameJoinRoute.route('/Game/Join2012.ashx', methods=['GET'])
def Join2012():
    SignedFirstTicketRaw : str = signUTF8("print('hello')", formatAutomatically=True, addNewLine=True, twelveclient=True)
    Resposne = make_response(SignedFirstTicketRaw)

    return Resposne

@GameJoinRoute.route('/game/validate-machine', methods=['POST'])
@csrf.exempt
def validate_machine():
    try:
        if request.cookies.get("t") is None:
            raise Exception("Request has no 't' cookie")
        
        macAddressesList = request.form.getlist('macAddresses')
        if len(macAddressesList) > 0:
            combinedAddress = ""
            for macAddress in macAddressesList:
                combinedAddress += macAddress
            UserHWIDHash = hashlib.sha256(combinedAddress.encode("utf-8")).hexdigest()
            tracking_cookie = request.cookies.get("t")
            redis_controller.setex(f"hwid:{str(tracking_cookie)}", 60, UserHWIDHash)
    except Exception as e:
        logging.error(f"Failed during /game/validate-machine: {e}")
    return jsonify({
        "success": True,
        "message": ""
    })

@GameJoinRoute.route('/Game/MachineConfiguration.ashx', methods=['POST', 'GET'])
@csrf.exempt
def machine_configuration():
    return ""

@GameJoinRoute.route('/v1/heartbeat', methods=['POST', 'GET'])
@csrf.exempt
def heartbeat():
    response = make_response(
        jsonify({
            "success": "true",
        })
    )
    return response

def ReturnPlaceLauncher( message : str, status : int, authenticated_userid : int = None) -> Response:
    response = make_response(
        jsonify({
            "jobId": None,
            "status": status,
            "joinScriptUrl": None,
            "authenticationUrl": config.BaseURL + "/Login/Negotiate.ashx",
            "authenticationTicket": None,
            "message": message,
            "rand": random.randint(0, 100000000000)
        })
    )

    if authenticated_userid is not None and request.cookies.get(".ROBLOSECURITY") is None:
        response.set_cookie( 
            key = ".ROBLOSECURITY",
            value = auth.CreateToken( userid = authenticated_userid, expireIn = 60 * 60 * 24 * 3, ip = get_remote_address() ),
            expires = datetime.utcnow() + timedelta(days=3),
            domain = f".{config.BaseDomain}"
        )
    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    return response


def BanUsersForSharedHwid(hwid: str, triggering_user_id: int | None = None) -> bool:
    MatchingHWIDLogs : list[UserHWIDLog] = UserHWIDLog.query.filter_by(hwid=hwid).distinct(UserHWIDLog.user_id).all()
    if len(MatchingHWIDLogs) < 2:
        return False

    MatchingUserIds = {log.user_id for log in MatchingHWIDLogs if log.user_id is not None}
    if len(MatchingUserIds) < 2:
        return False

    for user_id in MatchingUserIds:
        UserObj : User | None = User.query.filter_by(id=user_id).first()
        if UserObj is None:
            continue
        if AdminPermissions.query.filter_by(userid=UserObj.id).first() is not None:
            return False

    for user_id in MatchingUserIds:
        UserObj : User | None = User.query.filter_by(id=user_id).first()
        if UserObj is None:
            continue
        if UserObj.accountstatus != 1:
            continue
        ExistingActiveBan : UserBan | None = UserBan.query.filter_by(userid=UserObj.id, acknowledged=False).order_by(UserBan.id.desc()).first()
        if ExistingActiveBan is not None:
            continue

        BanObj = UserBan(
            userid=UserObj.id,
            author_userid=1,
            reason="Alting is not permitted on NEXIUM, contact us if you believe this is unfair",
            ban_type=BanType.Deleted,
            moderator_note=f"Automatic HWID alt ban for shared HWID {hwid}",
            expires_at=None
        )
        db.session.add(BanObj)
        UserObj.accountstatus = 3

    if MatchingUserIds:
        db.session.commit()
    return True

@GameJoinRoute.route('/game/PlaceLauncher.ashx', methods=['GET', 'POST'])
@GameJoinRoute.route('/Game/PlaceLauncher.ashx', methods=['GET', 'POST'])
@GameJoinRoute.route('/game/placelauncher.ashx', methods=['GET', 'POST'])
@GameJoinRoute.route('/Game/placelauncher.ashx', methods=['GET', 'POST'])
@csrf.exempt
def placelauncher():
    if not websiteFeatures.GetWebsiteFeature("GameJoinAPI"):
        logging.warning("opsec")
    
    AuthenticatdUser = None
    placeid = request.args.get( key = 'placeId', default = None, type = int) or request.args.get( key = 'placeid', default = None, type = int)
    ticket = request.args.get( key = 't', default = None, type = str)
    if ticket is None:
        AuthenticatdUser : User | None = auth.GetCurrentUser()
        if AuthenticatdUser is None:
            return ReturnPlaceLauncher("Invalid request", 12)
    if placeid is None:
        return ReturnPlaceLauncher("Invalid request", 12)
    
    requestedJobId = request.args.get( key = 'jobId', default = None, type = str) or request.args.get( key = 'jobid', default = None, type = str)
    isTeleport = request.args.get( key = 'isTeleport', default = None, type = str ) == "true"
    requestType = request.args.get( key = 'request', default = "RequestGame", type = str )

    if request.user_agent != "Roblox/WinInet":
        isTeleport = False
    
    authticketInfo = redis_controller.get(f"authticket:{ticket}")
    if authticketInfo is None and AuthenticatdUser is None:
        return ReturnPlaceLauncher("Invalid authentication ticket", 12)
    
    userId = int(authticketInfo) if authticketInfo is not None else AuthenticatdUser.id
    if PlaceServerPlayer.query.filter_by(userid=userId).first() is not None and not isTeleport:
        CurrentPlaceServerPlayerObj : PlaceServerPlayer = PlaceServerPlayer.query.filter_by(userid=userId).first()
        CurrentPlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=CurrentPlaceServerPlayerObj.serveruuid).first()
        try:
            EvictPlayer(CurrentPlaceServerObj, userId)
        except:
            return ReturnPlaceLauncher("Invalid request", 12)

    AssetObj : Asset = Asset.query.filter_by(id=placeid).first()
    if AssetObj is None:
        return ReturnPlaceLauncher("Invalid place", 14)
    if AssetObj.asset_type != AssetType.Place or AssetObj.moderation_status == 2:
        return ReturnPlaceLauncher("Invalid place", 14)
    PlaceObj : Place = Place.query.filter_by(placeid=placeid).first()
    if PlaceObj is None:
        return ReturnPlaceLauncher("Invalid place", 14)
    UniverseObj : Universe = Universe.query.filter_by(id=PlaceObj.parent_universe_id).first()
    if UniverseObj.is_public == False and userId != 1:
        return ReturnPlaceLauncher("Invalid place", 14)
    
    UserObj : User = User.query.filter_by(id=userId).first()

    if request.cookies.get("t") is not None:
        Tracking_Cookie = request.cookies.get("t")
        UserHWIDHash = redis_controller.get(f"hwid:{str(Tracking_Cookie)}")
        if UserHWIDHash is not None:
            UserHWIDLogObject = UserHWIDLog(
                user_id = UserObj.id,
                hwid = UserHWIDHash
            )
            db.session.add(UserHWIDLogObject)
            db.session.commit()

            redis_controller.delete(f"hwid:{str(Tracking_Cookie)}")

            if BanUsersForSharedHwid(UserHWIDHash, UserObj.id):
                return ReturnPlaceLauncher("Invalid session", 12)

    UserMembershipStatus : MembershipType = GetUserMembership(UserObj)
    if UniverseObj.bc_required and UserMembershipStatus == MembershipType.NonBuildersClub:
        return ReturnPlaceLauncher("Builders Club required", 12)
    if UniverseObj.minimum_account_age > (datetime.utcnow() - UserObj.created).days:
        return ReturnPlaceLauncher("Account is too new to join this place", 12)

    if requestedJobId is not None:
        PlaceServerObj : PlaceServer = PlaceServer.query.filter_by( serveruuid = requestedJobId, serverPlaceId = placeid, reservedServerAccessCode = None ).first()
        if PlaceServerObj is None:
            return ReturnPlaceLauncher("Invalid request", 12)
        if PlaceServerObj.maxPlayerCount <= PlaceServerObj.playerCount:
            return ReturnPlaceLauncher("Server is full", 6, authenticated_userid=userId)
    elif requestType == "RequestPrivateGame":
        server_accessCode = request.args.get( key = 'accessCode', default = None, type = str)
        if server_accessCode is None:
            return ReturnPlaceLauncher("Invalid request", 12)
        PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serverPlaceId = placeid, reservedServerAccessCode = server_accessCode ).first()
        if PlaceServerObj is None:
            return ReturnPlaceLauncher("Invalid request", 12)
        if redis_controller.exists(f"reservedserveraccesscode:{str(PlaceServerObj.serveruuid)}:{UserObj.id}") is False:
            return ReturnPlaceLauncher("Invalid request", 12)
        if PlaceServerObj.maxPlayerCount <= PlaceServerObj.playerCount:
            return ReturnPlaceLauncher("Server is full", 6, authenticated_userid=userId)
        
        redis_controller.delete(f"reservedserveraccesscode:{str(PlaceServerObj.serveruuid)}:{UserObj.id}")
    else:
        PlaceServerObj : PlaceServer | bool = GetSuitablePlaceServer( placeId = placeid )

    if PlaceServerObj is False:
        logging.info(f"Placelauncher.ashx : {str(placeid)} : {UserObj.username} [{UserObj.id}] : No available place servers found yet")
        return ReturnPlaceLauncher(None, 1, authenticated_userid=userId)
    authenticatedTicketUUID = str(uuid.uuid4())
    redis_controller.setex(f"place:{placeid}:ticket:{authenticatedTicketUUID}", 60, json.dumps({"id": userId, "jobid": str(PlaceServerObj.serveruuid)}))

    authticket = ''.join(random.choices(string.ascii_uppercase + string.digits, k=256))
    redis_controller.set(f"authticket:{authticket}", userId, 60*10)
    resp = make_response(jsonify({
        "jobId": PlaceServerObj.serveruuid,
        "status": 2,
        "joinScriptUrl": config.BaseURL + "/Game/Join.ashx?placeId=" + str(placeid) + "&jobId=" + str(PlaceServerObj.serveruuid) + "&ticket=" + authenticatedTicketUUID,
        "authenticationUrl": config.BaseURL + "/Login/Negotiate.ashx",
        "authenticationTicket": authticket,
        "message": None,
        "rand": random.randint(0, 100000000000)
    }))
    resp.set_cookie("ticket", authenticatedTicketUUID)
    if request.cookies.get(".ROBLOSECURITY") is None:
        resp.set_cookie( 
            key = ".ROBLOSECURITY",
            value = auth.CreateToken( userid = userId, expireIn = 60 * 60 * 24 * 3, ip = get_remote_address() ),
            expires = datetime.utcnow() + timedelta(days=3),
            domain = f".{config.BaseDomain}"
        )
    return resp

@GameJoinRoute.route("/v1/join-game", methods=["POST"]) # Meant for 2020 Android
@csrf.exempt
@auth.authenticated_required_api
def gamejoin_api_v1():
    if not websiteFeatures.GetWebsiteFeature("GameJoinAPI"):
        return ReturnPlaceLauncher("GameJoinAPI is disabled", 12)
    if not request.is_json:
        return ReturnPlaceLauncher("Invalid request", 12)
    if "placeId" not in request.json:
        return ReturnPlaceLauncher("Invalid request", 12)
    try:
        requestedPlaceId : int = int(request.json["placeId"])
        isTeleport : bool = request.json["isTeleport"] if "isTeleport" in request.json else False
    except:
        return ReturnPlaceLauncher("Invalid request", 12)

    AuthenticatedUser : User = auth.GetCurrentUser()
    if AuthenticatedUser is None: # Shouldnt happen but just in case
        return ReturnPlaceLauncher("Invalid request", 12)
    
    userId = AuthenticatedUser.id
    if PlaceServerPlayer.query.filter_by(userid=userId).first() is not None and not isTeleport:
        CurrentPlaceServerPlayerObj : PlaceServerPlayer = PlaceServerPlayer.query.filter_by(userid=userId).first()
        CurrentPlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=CurrentPlaceServerPlayerObj.serveruuid).first()
        try:
            EvictPlayer(CurrentPlaceServerObj, userId)
        except:
            logging.error(f"/v1/join-game - Failed to evict player {userId} from place {CurrentPlaceServerObj.serverPlaceId}")
            return ReturnPlaceLauncher("Invalid request", 12)
    
    AssetObj : Asset = Asset.query.filter_by(id=requestedPlaceId).first()
    if AssetObj is None:
        return ReturnPlaceLauncher("Invalid place", 14)
    if AssetObj.asset_type != AssetType.Place or AssetObj.moderation_status == 2:
        return ReturnPlaceLauncher("Invalid place", 14)
    PlaceObj : Place = Place.query.filter_by(placeid=requestedPlaceId).first()
    if PlaceObj is None:
        return ReturnPlaceLauncher("Invalid place", 14)
    UniverseObj : Universe = Universe.query.filter_by(id=PlaceObj.parent_universe_id).first()
    if UniverseObj.is_public == False and userId != 1:
        return ReturnPlaceLauncher("Invalid place", 14)
    
    UserMembershipStatus : MembershipType = GetUserMembership(AuthenticatedUser)
    if UniverseObj.bc_required and UserMembershipStatus == MembershipType.NonBuildersClub:
        return ReturnPlaceLauncher("Builders Club required", 12)
    if UniverseObj.minimum_account_age > (datetime.utcnow() - AuthenticatedUser.created).days:
        return ReturnPlaceLauncher("Account is too new to join this place", 12)
    
    PlaceServerObj : PlaceServer | bool = GetSuitablePlaceServer( placeId = requestedPlaceId )

    if PlaceServerObj is False:
        logging.info(f"/v1/join-game : {str(requestedPlaceId)} : {AuthenticatedUser.username} [{AuthenticatedUser.id}] : No available place servers found yet")
        return ReturnPlaceLauncher(None, 1, authenticated_userid=userId)
    
    ClientTicket = GenerateClientTicket(AuthenticatedUser, PlaceServerObj.serveruuid, TicketVersion = 4, PlaceId = requestedPlaceId)
    SessionId : str = f"{str(uuid.uuid4())}|{str(PlaceServerObj.serveruuid)}|0|{str(PlaceServerObj.serverIP)}|8|{datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')}|0|null|AAAAA"

    resp = make_response(jsonify({
        "jobId": PlaceServerObj.serveruuid,
        "status": 2,
        "authenticationUrl": config.BaseURL + "/Login/Negotiate.ashx",
        "authenticationTicket": "",
        "message": None,
        "rand": random.randint(0, 100000000000),
        "joinScript": {
            "ClientPort" : 0,
            "MachineAddress" : PlaceServerObj.serverIP,
            "ServerConnections": [
                {
                    "Port": PlaceServerObj.serverPort,
                    "Address": PlaceServerObj.serverIP
                }
            ],
            "ServerPort" : PlaceServerObj.serverPort,
            "PingUrl": "",
            "PingInterval": 120,
            "UserName": AuthenticatedUser.username,
            "DisplayName": AuthenticatedUser.username,
            "SeleniumTestMode": False,
            "UserId": AuthenticatedUser.id,
            "ClientTicket": ClientTicket,
            "SuperSafeChat": False,
            "PlaceId": PlaceServerObj.serverPlaceId,
            "MeasurementUrl": "",
            "WaitingForCharacterGuid": str(uuid.uuid4()),
            "BaseUrl": config.BaseURL,
            "ChatStyle": PlaceObj.chat_style.name,
            "VendorId": 0,
            "ScreenShotInfo": "",
            "VideoInfo": "",
            "CreatorId": AssetObj.creator_id,
            "CreatorTypeEnum": "User" if AssetObj.creator_type == 0 else "Group",
            "MembershipType": GetUserMembership(AuthenticatedUser, changeToString=True),
            "AccountAge": (datetime.utcnow() - AuthenticatedUser.created).days,
            "CookieStoreFirstTimePlayKey": "rbx_evt_ftp",
            "CookieStoreFiveMinutePlayKey": "rbx_evt_fmp",
            "CookieStoreEnabled": True,
            "IsRobloxPlace": False,
            "UniverseId": PlaceObj.parent_universe_id,
            "GenerateTeleportJoin": False,
            "IsUnknownOrUnder13": False,
            "SessionId": SessionId,
            "DataCenterId": 0,
            "FollowUserId": 0,
            "BrowserTrackerId": 0,
            "UsePortraitMode": False,
            "CharacterAppearance": f"http://www.nexium.fit/v1/avatar-fetch?userId={str(AuthenticatedUser.id)}&placeId={str(requestedPlaceId)}",
            "GameId": PlaceServerObj.serverPlaceId,
            "RobloxLocale": "en_us",
            "GameLocale": "en_us",
            "characterAppearanceId": AuthenticatedUser.id
        }
    }))
    return resp

def GenerateClientTicket( UserObj : User, JobId : str, CharacterURL : str = None, CustomTimestamp : str = "", TicketVersion : int = 1, PlaceId : int = 1) -> str:
    """
        Generates a client ticket so that RCC can verify the user is authenticated
        If CharacterURL is not None, it will be used as the character URL instead of the default
        If CustomTimestamp is not 0, it will be used as the timestamp instead of the current time
    """
    if CustomTimestamp == "":
        CustomTimestamp = datetime.utcnow().strftime("%m/%d/%Y %I:%M:%S %p")
    if CharacterURL is None:
        if TicketVersion == 2:
            CharacterURL = str(UserObj.id)
        elif TicketVersion == 1:
            CharacterURL = Config.BaseURL + "/Asset/CharacterFetch.ashx?userId=" + str(UserObj.id) # f"http://www.nexium.fit/v1.1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(PlaceId)}"
        elif TicketVersion == 4:
            CharacterURL = f"http://www.nexium.fit/v1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(PlaceId)}"
    
    FirstTicketUnsigned = f"{str(UserObj.id)}\n{UserObj.username}\n{CharacterURL}\n{JobId}\n{str(CustomTimestamp)}"
    SignedFirstTicketRaw : bytes = signUTF8(FirstTicketUnsigned, formatAutomatically=False, addNewLine=False, useNewKey=(TicketVersion > 1))
    SignedFirstTicket = base64.b64encode(SignedFirstTicketRaw).decode("utf-8")

    AccountAge = (datetime.utcnow() - UserObj.created).days
    UserMembershipType = GetUserMembership(UserObj, changeToString=True)

    if TicketVersion <= 3:
        SecondTicketUnsigned = f"{str(UserObj.id)}\n{str(JobId)}\n{str(CustomTimestamp)}"
    elif TicketVersion == 4:
        SecondTicketUnsigned = f"{CustomTimestamp}\n{JobId}\n{UserObj.id}\n{UserObj.id}\n0\n{AccountAge}\nf\n{len(UserObj.username)}\n{UserObj.username}\n{len(UserMembershipType)}\n{UserMembershipType}\n0\n\n0\n\n{len(UserObj.username)}\n{UserObj.username}"

    SignedSecondTicketRaw : bytes = signUTF8(SecondTicketUnsigned, formatAutomatically=False, addNewLine=False, useNewKey=(TicketVersion > 1))
    SignedSecondTicket = base64.b64encode(SignedSecondTicketRaw).decode("utf-8")

    return f"{str(CustomTimestamp)};{SignedFirstTicket};{SignedSecondTicket}{f';{TicketVersion}' if TicketVersion > 1 else ''}"

@GameJoinRoute.route('/Game/Join.ashx', methods=['GET', 'POST'])
@csrf.exempt
def join():
    placeid = request.args.get('placeId', default = None, type = int)
    jobid = request.args.get('jobId', default = None, type = str)
    ticket = request.args.get('ticket', default = None, type = str)
    if placeid is None or jobid is None or ticket is None:
        return 'Invalid request ( 0 )',400

    ticketInfo = redis_controller.get(f"place:{placeid}:ticket:{ticket}")
    if ticketInfo is None:
        return 'Invalid request ( 1 )',400
    ticketInfo = json.loads(ticketInfo)
    if ticketInfo['jobid'] != jobid:
        return 'Invalid request ( 2 )',400

    PlaceServerObj : PlaceServer = PlaceServer.query.filter_by(serveruuid=jobid).first()
    if PlaceServerObj is None:
        return 'Invalid request ( 3 )',400
    if PlaceServerObj.serverPlaceId != placeid:
        return 'Invalid request ( 4 )',400
    if PlaceServerObj.serverRunningTime == 0:
        return 'Invalid request ( 5 )',400
    if PlaceServerObj.maxPlayerCount <= PlaceServerObj.playerCount:
        return 'Invalid request ( 6 )',400
    UserObj : User = User.query.filter_by(id=ticketInfo['id']).first()
    if UserObj is None:
        return 'Invalid request ( 7 )',400
    PlaceObj : Place = Place.query.filter_by(placeid=placeid).first()
    AssetObj : Asset = Asset.query.filter_by(id=placeid).first()
    UniverseObj : Universe = Universe.query.filter_by(id=PlaceObj.parent_universe_id).first()
    if UniverseObj.place_year == PlaceYear.Sixteen:
        return 'Who is bro', 400
    ClientTicket = GenerateClientTicket(UserObj, jobid, TicketVersion = 1 if UniverseObj.place_year in [PlaceYear.Sixteen, PlaceYear.Fourteen] else ( 2 if UniverseObj.place_year == PlaceYear.Eighteen else 4), PlaceId = placeid)

    AuthenticationTicket = auth.CreateToken(UserObj.id, get_remote_address() , (60*60*24) )
    SessionId : str = f"{str(uuid.uuid4())}|{str(PlaceServerObj.serveruuid)}|0|{str(PlaceServerObj.serverIP)}|8|{datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z')}|0|null|{AuthenticationTicket}"
    
    UserMembershipStatus : str = GetUserMembership(UserObj, changeToString=True)
    JoinData : str = json.dumps({
        "ClientPort" : 0,
        "MachineAddress" : PlaceServerObj.serverIP,
        "ServerConnections": [
            {
                "Port": PlaceServerObj.serverPort,
                "Address": PlaceServerObj.serverIP
            }
        ],
        "ServerPort" : PlaceServerObj.serverPort,
        "PingUrl": "",
        "PingInterval": 120,
        "UserName": UserObj.username,
        "DisplayName": UserObj.username,
        "SeleniumTestMode": False,
        "UserId": UserObj.id,
        "ClientTicket": ClientTicket,
        "SuperSafeChat": False,
        "PlaceId": PlaceServerObj.serverPlaceId,
        "MeasurementUrl": "",
        "WaitingForCharacterGuid": str(uuid.uuid4()),
        "BaseUrl": config.BaseURL,
        "ChatStyle": PlaceObj.chat_style.name,
        "VendorId": 0,
        "ScreenShotInfo": "",
        "VideoInfo": "",
        "CreatorId": AssetObj.creator_id,
        "CreatorTypeEnum": "User" if AssetObj.creator_type == 0 else "Group",
        "MembershipType": UserMembershipStatus,
        "AccountAge": (datetime.utcnow() - UserObj.created).days,
        "CookieStoreFirstTimePlayKey": "rbx_evt_ftp",
        "CookieStoreFiveMinutePlayKey": "rbx_evt_fmp",
        "CookieStoreEnabled": True,
        "IsRobloxPlace": False,
        "UniverseId": PlaceObj.parent_universe_id,
        "GenerateTeleportJoin": False,
        "IsUnknownOrUnder13": False,
        "SessionId": SessionId,
        "DataCenterId": 0,
        "FollowUserId": 0,
        "BrowserTrackerId": 0,
        "UsePortraitMode": False,
        "CharacterAppearance": config.BaseURL + "/Asset/CharacterFetch.ashx?userId=" + str(UserObj.id) if PlaceObj.placeyear in [PlaceYear.Fourteen, PlaceYear.Sixteen] else ( f"http://www.nexium.fit/v1.1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(placeid)}" if PlaceObj.placeyear in [PlaceYear.Eighteen] else f"http://www.nexium.fit/v1/avatar-fetch?userId={str(UserObj.id)}&placeId={str(placeid)}" ),
        "GameId": PlaceServerObj.serverPlaceId,
        "RobloxLocale": "en_us",
        "GameLocale": "en_us",
        "characterAppearanceId": UserObj.id
    })
    if UniverseObj.place_year == PlaceYear.Sixteen:
        SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False)
    elif UniverseObj.place_year == PlaceYear.Eighteen:
        SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False, useNewKey=True)
    elif UniverseObj.place_year == PlaceYear.Twenty:
        SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False, useNewKey=True)
    elif UniverseObj.place_year == PlaceYear.Fourteen:
        JoinData = json.loads(JoinData)
        VerificationTicket = str(uuid.uuid4())
        JoinData["CharacterAppearance"] = f"{config.BaseURL}/Asset/CharacterFetch.ashx?userId={str(UserObj.id)}&t={VerificationTicket}&legacy=1"
        redis_controller.set(
            f"joinashx-auth:{str(jobid)}:{str(UserObj.id)}:{placeid}:{VerificationTicket}",
            json.dumps({
                "CharacterAppearance": JoinData["CharacterAppearance"],
                "Username": JoinData["UserName"]
            }),
            ex = 60
        )
        JoinData = json.dumps(JoinData)

        SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False)
    elif UniverseObj.place_year == PlaceYear.TwentyOne:
        SignedJoinData : str = signUTF8("\r\n"+JoinData, addNewLine=False, useNewKey=True)
    else:
        return 'Invalid request ( 8 )',400
    
    if UniverseObj.place_year in [ PlaceYear.Fourteen, PlaceYear.Sixteen ]:
        redis_controller.set(f"allow_join:{str(UserObj.id)}:{str(placeid)}:{str(jobid)}", "1", ex=120)

    joinResposne = make_response(SignedJoinData)
    joinResposne.headers['Content-Type'] = 'text/plain'
    joinResposne.set_cookie(
        key = "Syntax-Session-Id",
        value = SessionId,
        expires = datetime.utcnow() + timedelta(days=1),
        domain = f".{config.BaseDomain}"
    )
    if request.cookies.get(".ROBLOSECURITY") is None: # 2018 and 2020 doesen't want to authenticate properly with negotiate.ashx :(, so this is my hack till i can figure out why
        joinResposne.set_cookie( 
            key = ".ROBLOSECURITY",
            value = auth.CreateToken( userid = UserObj.id, expireIn = 60 * 60 * 24 * 3, ip = get_remote_address() ),
            expires = datetime.utcnow() + timedelta(days=3),
            domain = f".{config.BaseDomain}"
        )
    return joinResposne
