Make a requirements.txt folder

import asyncio
import aiohttp
from aiolimiter import AsyncLimiter
import time
from bisect import bisect_left
from tqdm.asyncio import tqdm
import backoff
import logging
import numpy as np
from scipy import stats
from scipy.optimize import brentq, minimize_scalar
from google.oauth2 import service_account
from googleapiclient.discovery import build
import discord
from discord import app_commands
from discord.ext import commands
import gspread
from oauth2client.service_account import ServiceAccountCredentials

# Import all configuration variables
from config import *

# --- Logging Setup ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)

# --- Discord Bot Setup ---
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix='!', intents=intents)

# --- Google Sheets Setup ---
scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive']
creds = ServiceAccountCredentials.from_json_keyfile_name(SERVICE_ACCOUNT_FILE, scope)
client = gspread.authorize(creds)
sheet = client.open('Global Rankings - Polytrack').worksheet('0.4')

# --- Cooldown Implementation for Application Commands ---
_command_cooldowns = {}


# --- Helper Functions ---

def calculate_rank_distribution(n_ranks, std_dev, skew_factor):
    x = np.linspace(0, 1, n_ranks + 1)
    percentiles = 1 - (1 - x) ** skew_factor
    z_scores = np.clip(stats.norm.ppf(percentiles), -8, 8)
    probs = np.diff(stats.norm.cdf(z_scores))
    return probs / np.sum(probs)


def skew_normal_pdf(x, alpha):
    return 2 / np.sqrt(2 * np.pi) * np.exp(-x ** 2 / 2) * stats.norm.cdf(alpha * x)


def find_cutoff(cdf_value, alpha, std_dev):
    return brentq(lambda x: stats.norm.cdf(x) - stats.norm.cdf(0) + cdf_value - stats.norm.cdf(alpha * x),
                  -10 * std_dev, 10 * std_dev)


def calculate_percentile_cutoffs(n_ranks, skew_factor):
    cdf_values = np.cumsum(calculate_rank_distribution(n_ranks, 1, skew_factor))
    return [find_cutoff(cdf_value, skew_factor, 1) for cdf_value in cdf_values]


def get_tier(rank, total_players):
    percentile = rank / total_players
    tier_cutoffs = calculate_percentile_cutoffs(N_TIERS, SKEW_FACTOR)
    return np.digitize([percentile], tier_cutoffs, right=True)[0] + 1


def format_average_rank(rank):
    if rank < 10:
        return f"{rank:.2f}"
    elif rank < 100:
        return f"{rank:.1f}"
    else:
        return f"{rank:.0f}"


def get_tier_distribution(n_tiers, highest_tier_percentage, skew_factor):
    def target_function(sigma):
        dist = calculate_rank_distribution(n_tiers, sigma, skew_factor)
        return abs(dist[0] - highest_tier_percentage)

    result = minimize_scalar(target_function, bounds=(0.01, 100), method='bounded')
    sigma_optimized = result.x

    return calculate_rank_distribution(n_tiers, sigma_optimized, skew_factor)


tier_distribution = get_tier_distribution(N_TIERS, HIGHEST_TIER_PERCENTAGE, SKEW_FACTOR)


def get_tier(percentile):
    cumulative_percentage = 0
    for i, threshold in enumerate(tier_distribution):
        cumulative_percentage += threshold
        if percentile <= cumulative_percentage * 100:
            return i + 1
    return N_TIERS


def get_tier_name(tier_number):
    """Returns the name of the tier given its number."""
    return TIER_NAMES[tier_number - 1]


# Create a limiter that allows 10 requests per second
limiter = AsyncLimiter(10, 1)  # 10 requests per 1 second

@backoff.on_exception(backoff.expo,
                      (aiohttp.ClientError, asyncio.TimeoutError),
                      max_tries=RETRIES,
                      jitter=backoff.full_jitter,
                      max_time=60)  # Set a maximum backoff time of 60 seconds
async def fetch_data(session, url_template, skip, track_index):
    async with limiter:  # Use the limiter here
        url = url_template.format(skip=skip)
        try:
            async with session.get(url, timeout=30) as response:
                if response.status == 200:
                    data = await response.json()
                    return data.get("entries", [])
                elif response.status == 503:
                    logger.warning(f"Server overloaded (503). Retrying URL: {url}")
                    raise aiohttp.ClientError
                else:
                    logger.error(f"Error fetching data from {url}. Status code: {response.status}. Retrying...")
                    raise aiohttp.ClientError
        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
            logger.error(f"Exception for URL {url}: {e}")
            raise

async def process_track(session, url_template, track_index, pbar):
    skip = 0
    max_entries = MAX_SKIP

    while skip < max_entries:
        try:
            entries = await fetch_data(session, url_template, skip, track_index)
            if entries:
                for entry in entries:
                    player_name = entry["name"]
                    frames = entry["frames"]
                    player_times.setdefault(player_name, [None] * len(TRACK_URLS))
                    if player_times[player_name][track_index] is None or frames < player_times[player_name][track_index]:
                        player_times[player_name][track_index] = frames
                sorted_track_times[url_template] = sorted([t for t in player_times.values() if t[track_index] is not None],
                                                          key=lambda x: x[track_index])
                skip += len(entries)
            else:
                logger.info(f"No more entries found for track {track_index} at skip {skip}.")
                break  # Exit only if no more entries are found
        except aiohttp.ClientError as e:
            logger.error(f"Failed to fetch data for {url_template.format(skip=skip)} after retries. Error: {e}.")
            # DO NOT BREAK HERE. Let backoff retry.
        finally:  # Ensure pbar update even on error
            pbar.update(len(entries) if entries else 0) # Update with 0 if entries is None due to an error



def clear_google_sheet_columns():
    SCOPES = ['https://www.googleapis.com/auth/spreadsheets']

    credentials = service_account.Credentials.from_service_account_file(
        SERVICE_ACCOUNT_FILE, scopes=SCOPES)

    service = build('sheets', 'v4', credentials=credentials)

    RANGE_NAME = '0.4!B1:Z'

    body = {}

    result = service.spreadsheets().values().clear(
        spreadsheetId=SPREADSHEET_ID, range=RANGE_NAME, body=body).execute()

    print(f"{result.get('clearedRange')} range cleared.")

    result = service.spreadsheets().values().update(
        spreadsheetId=SPREADSHEET_ID, range=RANGE_NAME,
        valueInputOption='USER_ENTERED', body=body).execute()

    print(f"{result.get('updatedCells')} cells updated.")


def update_google_sheet(data):
    SCOPES = ['https://www.googleapis.com/auth/spreadsheets']

    credentials = service_account.Credentials.from_service_account_file(
        SERVICE_ACCOUNT_FILE, scopes=SCOPES)

    service = build('sheets', 'v4', credentials=credentials)

    RANGE_NAME = '0.4!B1'

    clear_google_sheet_columns()

    body = {
        'values': data
    }

    result = service.spreadsheets().values().update(
        spreadsheetId=SPREADSHEET_ID, range=RANGE_NAME,
        valueInputOption='USER_ENTERED', body=body).execute()

    print(f"{result.get('updatedCells')} cells updated.")


# --- Discord Bot Commands ---

@bot.event
async def on_ready():
    print(f'{bot.user} has connected to Discord!')
    await bot.tree.sync()


async def check_cooldown(interaction: discord.Interaction, command_name: str) -> bool:
    current_time = time.time()
    if command_name == "rankings":  # bypass cooldown for "rankings" command
        return True
    cooldown_duration = RELEASE_COOLDOWN_TIME if command_name == "updaterelease" else COOLDOWN_TIME

    if command_name in _command_cooldowns and current_time < _command_cooldowns[command_name]:
        remaining_time = _command_cooldowns[command_name] - current_time
        await interaction.response.send_message(
            f"This command is on cooldown. Try again in {remaining_time:.2f} seconds.",
            ephemeral=True,
        )
        return False
    else:
        _command_cooldowns[command_name] = current_time + cooldown_duration
        return True


@bot.tree.command(name="player", description="Get stats for a player")
@app_commands.describe(player_name="The name of the player to look up")
async def player(interaction: discord.Interaction, player_name: str):
    if await check_cooldown(interaction, "player"):
        await interaction.response.defer()
        try:
            await update_rankings_function()
            cells = sheet.findall(player_name, in_column=4, case_sensitive=True)

            if cells:
                cell = cells[0]
                row_number = cell.row
                stats = sheet.row_values(row_number)

                headers = sheet.row_values(1)
                tier_image_url = stats[-1]

                # Get tier name directly from the tier_images dictionary
                for tier_number, image_url in TIER_IMAGES.items():
                    if image_url == tier_image_url:
                        tier_name = get_tier_name(tier_number)
                        break  # Exit loop once tier name is found

                embed = discord.Embed(title=f"Stats for {player_name}")

                # Add Tier before Rank
                embed.add_field(name="Tier", value=tier_name, inline=True)
                embed.add_field(name=headers[1], value=stats[1], inline=True)  # Rank

                # Add the rest of the fields, skipping "Tier" header
                for i, (header, stat) in enumerate(zip(headers[2:-1], stats[2:-1])):
                    if i != 1:  # Skip the "Rank" header and value
                        embed.add_field(name=header, value=stat, inline=True)

                embed.set_image(url=tier_image_url)

                await interaction.followup.send(embed=embed)
            else:
                await interaction.followup.send(
                    f"{player_name} not found. Please complete every track!")
        except Exception as e:
            await interaction.followup.send(f"An error occurred: {e}")


@bot.tree.command(name="rankings", description="Get the link to the global rankings")
async def rankings(interaction: discord.Interaction):
    if await check_cooldown(interaction, "rankings"):
        await interaction.response.send_message(
            f"<https://docs.google.com/spreadsheets/d/1szNRW_nla-XuGTwgIdGHdmuyd2HhTQ8NqWoqn6l9uGw/edit?usp=sharing>")


@bot.tree.command(name="wr", description="Shows the world record for each track and players with the most WRs.")
async def world_records(interaction: discord.Interaction):
    if await check_cooldown(interaction, "wr"):
        await interaction.response.defer()
        embed = discord.Embed(title="Polytrack World Records", color=discord.Color.blue())
        wr_holders = {}  # Dictionary to store players and their WR count

        async with aiohttp.ClientSession() as session:
            for i, url in enumerate(TRACK_URLS[:13]):
                try:
                    async with session.get(url.format(skip=0), timeout=30) as response:
                        if response.status == 200:
                            data = await response.json()
                            entries = data.get("entries", [])
                            if entries:
                                world_record_frames = entries[0]["frames"]
                                world_record_seconds = world_record_frames / 1000
                                world_record_holder = entries[0]["name"]
                                embed.add_field(name=TRACK_NAMES[i],
                                                value=f"**{world_record_seconds:.3f}s** by **{world_record_holder}**",
                                                inline=False)

                                # Update WR count for the player
                                if world_record_holder in wr_holders:
                                    wr_holders[world_record_holder] += 1
                                else:
                                    wr_holders[world_record_holder] = 1
                        else:
                            embed.add_field(name=TRACK_NAMES[i],
                                            value=f"Error fetching data. Status code: {response.status}",
                                            inline=False)
                except Exception as e:
                    embed.add_field(name=TRACK_NAMES[i],
                                    value=f"An error occurred: {e}",
                                    inline=False)

        # Sort players by WR count in descending order
        sorted_wr_holders = sorted(wr_holders.items(), key=lambda item: item[1], reverse=True)

        # Add WR holders and their count to the embed without the header
        embed.add_field(name="-" * 20, value="", inline=False)  # Add a separator line without space
        for player, count in sorted_wr_holders:
            embed.add_field(name=player, value=f"{count} WRs", inline=False)

        await interaction.followup.send(embed=embed)


import discord
from discord import app_commands
import numpy as np

# ... (Your other imports, TRACK_URLS, sorted_track_times, player_times, etc.)

@bot.tree.command(name="improvement", description="Get a player's improvement potential for each track.")
@app_commands.describe(player_name="The name of the player to analyze.")
async def improvement(interaction: discord.Interaction, player_name: str):
    if await check_cooldown(interaction, "improvement"):
        await interaction.response.defer()
        try:
            await update_rankings_function()

            player_data = player_times.get(player_name)
            if player_data is None:
                await interaction.followup.send(f"Player '{player_name}' not found in the rankings.")
                return

            table_data = []
            total_pb_seconds = 0
            total_wr_seconds = 0
            for i, track_url in enumerate(TRACK_URLS):
                pb_frames = player_data[i]
                if pb_frames is None:
                    table_data.append(
                        [f"T{i+1}", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A"])
                    continue

                wr_frames = sorted_track_times[track_url][0][i]
                pb_seconds = pb_frames / 1000
                wr_seconds = wr_frames / 1000

                total_pb_seconds += pb_seconds
                total_wr_seconds += wr_seconds

                # Calculate metrics with significant figures
                difference = pb_seconds - wr_seconds
                sig_figs = 3  # Example: using 3 significant figures

                # Function to format a number with significant figures
                def format_sigfigs(value, sig_figs):
                    if value == 0:
                        return "0"
                    return f"{float(f'{value:.{sig_figs}g}')}"

                difference_str = format_sigfigs(difference, sig_figs)
                inverse_performance_ratio_str = format_sigfigs(wr_seconds / pb_seconds if pb_seconds else float("inf"),
                                                               sig_figs)
                relative_difference_str = format_sigfigs(difference / wr_seconds if wr_seconds else float("inf"),
                                                         sig_figs)
                logarithmic_difference_str = format_sigfigs(
                    np.log(pb_seconds / wr_seconds) if wr_seconds and pb_seconds > 0 else float("inf"), sig_figs)
                harmonic_mean_str = format_sigfigs(
                    2 * (pb_seconds * wr_seconds) / (pb_seconds + wr_seconds) if pb_seconds + wr_seconds else float(
                        "inf"), sig_figs)
                normalized_difference_str = format_sigfigs(
                    difference / (pb_seconds + wr_seconds) if pb_seconds + wr_seconds else float("inf"), sig_figs)
                exponential_difference_str = format_sigfigs(
                    np.exp(difference / wr_seconds) if wr_seconds else float("inf"), sig_figs)
                inverse_relative_difference_str = format_sigfigs(
                    wr_seconds / difference if difference else float("inf"), sig_figs)
                quadratic_difference_str = format_sigfigs(
                    (difference / wr_seconds) ** 2 if wr_seconds else float("inf"), sig_figs)

                table_data.append([
                    f"T{i+1}",  # Abbreviated Track column
                    f"{pb_seconds:.3f}",
                    f"{wr_seconds:.3f}",
                    difference_str,
                    inverse_performance_ratio_str,
                    relative_difference_str,
                    logarithmic_difference_str,
                    harmonic_mean_str,
                    normalized_difference_str,
                    exponential_difference_str,
                    inverse_relative_difference_str,
                    quadratic_difference_str
                ])

            # Total Row Calculations (using the same significant figures)
            total_difference = total_pb_seconds - total_wr_seconds
            total_difference_str = format_sigfigs(total_pb_seconds - total_wr_seconds, sig_figs)
            total_inverse_performance_ratio_str = format_sigfigs(
                total_wr_seconds / total_pb_seconds if total_pb_seconds else float("inf"), sig_figs)
            total_relative_difference_str = format_sigfigs(
                total_difference / total_wr_seconds if total_wr_seconds else float("inf"), sig_figs)
            total_logarithmic_difference_str = format_sigfigs(
                np.log(total_pb_seconds / total_wr_seconds) if total_wr_seconds and total_pb_seconds > 0 else float(
                    "inf"), sig_figs)
            total_harmonic_mean_str = format_sigfigs(2 * (total_pb_seconds * total_wr_seconds) / (
                    total_pb_seconds + total_wr_seconds) if total_pb_seconds + total_wr_seconds else float("inf"),
                                                     sig_figs)
            total_normalized_difference_str = format_sigfigs(total_difference / (
                    total_pb_seconds + total_wr_seconds) if total_pb_seconds + total_wr_seconds else float("inf"),
                                                             sig_figs)
            total_exponential_difference_str = format_sigfigs(
                np.exp(total_difference / total_wr_seconds) if total_wr_seconds else float("inf"), sig_figs)
            total_inverse_relative_difference_str = format_sigfigs(
                total_wr_seconds / total_difference if total_difference else float("inf"), sig_figs)
            total_quadratic_difference_str = format_sigfigs(
                (total_difference / total_wr_seconds) ** 2 if total_wr_seconds else float("inf"), sig_figs)

            table_data.append([
                "All",
                f"{total_pb_seconds:.3f}",
                f"{total_wr_seconds:.3f}",
                total_difference_str,
                total_inverse_performance_ratio_str,
                total_relative_difference_str,
                total_logarithmic_difference_str,
                total_harmonic_mean_str,
                total_normalized_difference_str,
                total_exponential_difference_str,
                total_inverse_relative_difference_str,
                total_quadratic_difference_str
            ])

            # Compact column widths further
            table_str = "```\n"
            table_str += f"{'T':<3} {'PB':<7} {'WR':<7} {'Diff':<5} {'IPR':<5} {'RelD':<8} {'LogD':<8} {'Harm':<5} {'NormD':<8} {'ExpD':<6} {'InvRD':<7} {'QuadD':<6}\n"  # Shortened column names
            table_str += "-" * 94 + "\n"  # Adjusted separator length
            for row in table_data:
                table_str += f"{row[0]:<3} {row[1]:<7} {row[2]:<7} {row[3]:<5} {row[4]:<5} {row[5]:<8} {row[6]:<8} {row[7]:<5} {row[8]:<8} {row[9]:<6} {row[10]:<7} {row[11]:<6}\n"
            table_str += "```\n"

            await interaction.followup.send(f"Improvement potential for **{player_name}**:\n{table_str}")

        except Exception as e:
            await interaction.followup.send(f"An error occurred: {e}")


# --- Metric Explanation Commands ---

def create_metric_explanation_command(metric_name, explanation):
    """Creates a slash command function for a metric explanation."""

    @bot.tree.command(name=metric_name.lower(), description=f"Explanation of the {metric_name} metric.")
    async def metric_explanation_command(interaction: discord.Interaction):
        if await check_cooldown(interaction, metric_name.lower()):
            embed = discord.Embed(title=f"{metric_name} Metric Explanation", color=discord.Color.blue())
            embed.add_field(name="Explanation", value=explanation, inline=False)
            await interaction.response.send_message(embed=embed)

    return metric_explanation_command


# Create a command for each metric explanation from config.py
for metric_name, explanation in METRIC_EXPLANATIONS.items():
    create_metric_explanation_command(metric_name, explanation)


@bot.tree.command(name="help", description="Show available commands and their usage.")
async def help_command(interaction: discord.Interaction):
    if await check_cooldown(interaction, "help"):
        embed = discord.Embed(title="Available Commands", color=discord.Color.blue())
        embed.add_field(name="/player <player_name>", value="Get stats for a player.", inline=False)
        embed.add_field(name="/rankings", value="Get the link to the global rankings.", inline=False)
        embed.add_field(name="/wr", value="Shows the world record for each track.", inline=False)
        embed.add_field(name="/improvement <player_name>",
                        value="Shows the improvement potential for each track for the given player.", inline=False)
        embed.add_field(name="/help", value="Show this help message.", inline=False)

        # Add a general note about metric explanation commands
        embed.add_field(name="Metric Explanations",
                        value="For detailed explanations of each metric, use commands like `/diff`, `/ipr`, `/reldiff`, etc.",
                        inline=False)

        await interaction.response.send_message(embed=embed)


# --- Main Ranking Update Function ---
async def update_rankings_function():
    start_time = time.time()

    connector = aiohttp.TCPConnector(limit_per_host=13)
    async with aiohttp.ClientSession(connector=connector) as session:
        with tqdm(total=MAX_SKIP * len(TRACK_URLS), desc="Processing tracks") as pbar:
            tasks = [process_track(session, url_template, i, pbar) for i, url_template in enumerate(TRACK_URLS)]
            await asyncio.gather(*tasks)

    player_data = {}
    for player, times in player_times.items():
        if all(t is not None for t in times):
            total_time = sum(times)
            ranks = [bisect_left(sorted_track_times[TRACK_URLS[i]], times[i], key=lambda x: x[i]) + 1 for i in
                     range(len(TRACK_URLS))]
            average_rank = sum(ranks) / len(ranks)
            best_rank = min(ranks)
            worst_rank = max(ranks)

            player_data[player] = {
                "total_time": total_time,
                "average_rank": average_rank,
                "best_rank": best_rank,
                "worst_rank": worst_rank,
                "track_times": times,
                "ranks": ranks
            }

    sorted_players = sorted(player_data.items(), key=lambda x: x[1]["total_time"])

    sheet_data = []

    header = ["Rank", "Percentile", "Player Name", "Total Time", "Average Rank", "Best Rank", "Worst Rank"]
    header.extend(TRACK_NAMES)
    header.append("Tier")
    sheet_data.append(header)

    for i, (player, data) in enumerate(sorted_players, start=1):
        overall_percentile = (len(sorted_players) - i + 1) / len(sorted_players) * 100
        tier = get_tier(overall_percentile)
        tier = max(1, min(tier, N_TIERS))
        tier_image = TIER_IMAGES.get(tier, "")

        total_seconds = data["total_time"] / 1000
        hours, remainder = divmod(total_seconds, 3600)
        minutes, seconds = divmod(remainder, 60)
        milliseconds = int((seconds % 1) * 1000)
        seconds = int(seconds)
        minutes = int(minutes)
        hours = int(hours)

        if hours > 0:
            formatted_total_time = f"{hours}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"
        elif minutes > 0:
            formatted_total_time = f"{minutes}:{seconds:02d}.{milliseconds:03d}"
        else:
            formatted_total_time = f"{seconds}.{milliseconds:03d}"

        formatted_average_rank = format_average_rank(data['average_rank'])

        row = [i, f"{overall_percentile:.2f}", f"'{player}", formatted_total_time, formatted_average_rank,
               data["best_rank"], data["worst_rank"]]
        for j in range(len(TRACK_URLS)):
            track_time = data["track_times"][j]
            rank = data["ranks"][j]
            formatted_track_time = f"{track_time / 1000:.3f}"
            row.append(f"{formatted_track_time}s (#{rank})")
        row.append(tier_image)
        sheet_data.append(row)

    update_google_sheet(sheet_data)

    end_time = time.time()
    total_time = end_time - start_time
    hours, remainder = divmod(total_time, 3600)
    minutes, seconds = divmod(remainder, 60)
    milliseconds = int((seconds % 1) * 1000)
    seconds = int(seconds)
    minutes = int(minutes)
    hours = int(hours)

    if hours > 0:
        formatted_time = f"{hours}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"
    elif minutes > 0:
        formatted_time = f"{minutes}:{seconds:02d}.{milliseconds:03d}"
    else:
        formatted_time = f"{seconds}.{milliseconds:03d}"

    print(f"Data updated in Google Sheets")
    print(f"Total execution time: {formatted_time}")


# --- Run the Discord Bot ---
bot.run(DISCORD_BOT_TOKEN)