from dataclasses import dataclass
from enum import IntEnum
import json
import logging
from pathlib import Path
import random
from typing import Optional, Union

from raylib import SHADER_UNIFORM_VEC3
from libs.audio import audio
from libs.animation import Animation, MoveAnimation
from libs.global_data import Crown, Difficulty, ScoreMethod
from libs.tja import TJAParser, test_encodings
from libs.texture import tex
from libs.utils import OutlinedText, get_current_ms, global_data
from datetime import datetime, timedelta
import sqlite3
import pyray as ray

BOX_CENTER = 594 * tex.screen_scale

logger = logging.getLogger(__name__)

def rgb_to_hue(r, g, b):
    rf = r / 255.0
    gf = g / 255.0
    bf = b / 255.0

    max_val = max(rf, gf, bf)
    min_val = min(rf, gf, bf)
    delta = max_val - min_val

    if delta == 0:
        return 0  # Gray/white, no hue

    if max_val == rf:
        hue = 60.0 * (((gf - bf) / delta) % 6)
    elif max_val == gf:
        hue = 60.0 * ((bf - rf) / delta + 2.0)
    else:
        hue = 60.0 * ((rf - gf) / delta + 4.0)

    if hue < 0:
        hue += 360.0

    return hue


def calculate_hue_shift(source_rgb, target_rgb):
    source_hue = rgb_to_hue(*source_rgb)
    target_hue = rgb_to_hue(*target_rgb)

    shift = (target_hue - source_hue) / 360.0

    while shift < 0:
        shift += 1.0
    while shift >= 1.0:
        shift -= 1.0

    return shift

def darken_color(rgb: tuple[int, int, int]):
    r, g, b = rgb
    darkening_factor = 0.63

    darkened_r = int(r * darkening_factor)
    darkened_g = int(g * darkening_factor)
    darkened_b = int(b * darkening_factor)

    return (darkened_r, darkened_g, darkened_b)

class TextureIndex(IntEnum):
    BLANK = 0
    VOCALOID = 1
    DEFAULT = 2
    RECOMMENDED = 3
    FAVORITE = 4
    RECENT = 5

class GenreIndex(IntEnum):
    TUTORIAL = 0
    JPOP = 1
    ANIME = 2
    VOCALOID = 3
    CHILDREN = 4
    VARIETY = 5
    CLASSICAL = 6
    GAME = 7
    NAMCO = 8
    DEFAULT = 9
    RECOMMENDED = 10
    FAVORITE = 11
    RECENT = 12
    DAN = 13
    DIFFICULTY = 14

class BaseBox():
    """Base class for all box types in the song select screen."""
    def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex):
        self.text_name = name
        self.texture_index = texture_index
        self.genre_index = GenreIndex.DEFAULT
        self.back_color = back_color
        if fore_color is not None:
            self.fore_color = ray.Color(fore_color[0], fore_color[1], fore_color[2], 255)
        elif self.back_color is not None:
            dark_ver = darken_color(self.back_color)
            self.fore_color = ray.Color(dark_ver[0], dark_ver[1], dark_ver[2], 255)
        else:
            self.fore_color = ray.Color(101, 0, 82, 255)
        self.position = float('inf')
        self.start_position = -1.0
        self.target_position = -1.0
        self.open_anim = Animation.create_move(233, total_distance=150*tex.screen_scale, delay=50)
        self.open_fade = Animation.create_fade(200, initial_opacity=0, final_opacity=1.0)
        self.move = None
        self.shader = None
        self.is_open = False
        self.text_loaded = False
        self.wait = 0

    def load_text(self):
        font_size = tex.skin_config["song_box_name"].font_size
        if len(self.text_name) >= 30:
            font_size -= int(10 * tex.screen_scale)
        self.name = OutlinedText(self.text_name, font_size, ray.WHITE, outline_thickness=5, vertical=True)
        if self.back_color is not None:
            self.shader = ray.load_shader('shader/dummy.vs', 'shader/colortransform.fs')
            source_rgb = (142, 212, 30)
            target_rgb = self.back_color
            source_color = ray.ffi.new('float[3]', [source_rgb[0]/255.0, source_rgb[1]/255.0, source_rgb[2]/255.0])
            target_color = ray.ffi.new('float[3]', [target_rgb[0]/255.0, target_rgb[1]/255.0, target_rgb[2]/255.0])
            source_loc = ray.get_shader_location(self.shader, 'sourceColor')
            target_loc = ray.get_shader_location(self.shader, 'targetColor')
            ray.set_shader_value(self.shader, source_loc, source_color, SHADER_UNIFORM_VEC3)
            ray.set_shader_value(self.shader, target_loc, target_color, SHADER_UNIFORM_VEC3)

    def move_box(self, current_time: float):
        if self.position != self.target_position and self.move is None:
            if self.position < self.target_position:
                direction = 1
            else:
                direction = -1
            if abs(self.target_position - self.position) > 250 * tex.screen_scale:
                direction *= -1
            self.move = Animation.create_move(133, total_distance=100 * direction * tex.screen_scale, ease_out='cubic')
            self.move.start()
            if self.is_open or self.target_position == BOX_CENTER:
                self.move.total_distance = int(250 * direction * tex.screen_scale)
            self.start_position = self.position
        if self.move is not None:
            self.move.update(current_time)
            self.position = self.start_position + int(self.move.attribute)
            if self.move.is_finished:
                self.position = self.target_position
                self.move = None

    def update(self, current_time: float, is_diff_select: bool):
        self.is_diff_select = is_diff_select
        self.open_anim.update(current_time)
        self.open_fade.update(current_time)

    def _draw_closed(self, x: float, y: float, outer_fade_override: float):
        if self.shader is not None and self.texture_index == TextureIndex.BLANK:
            ray.begin_shader_mode(self.shader)
        tex.draw_texture('box', 'folder_texture_left', frame=self.texture_index, x=x, fade=outer_fade_override)
        tex.draw_texture('box', 'folder_texture', frame=self.texture_index, x=x, x2=tex.skin_config["song_box_bg"].width, fade=outer_fade_override)
        tex.draw_texture('box', 'folder_texture_right', frame=self.texture_index, x=x, fade=outer_fade_override)
        if self.shader is not None and self.texture_index == TextureIndex.BLANK:
            ray.end_shader_mode()
        if self.texture_index == TextureIndex.DEFAULT:
            tex.draw_texture('box', 'genre_overlay', x=x, y=y, fade=outer_fade_override)
        if self.genre_index == GenreIndex.DIFFICULTY:
            tex.draw_texture('box', 'diff_overlay', x=x, y=y, fade=outer_fade_override)

    def _draw_open(self, x: float, y: float, fade_override: Optional[float], is_ura: bool):
        pass

    def draw(self, x: float, y: float, is_ura: bool, inner_fade_override: Optional[float] = None, outer_fade_override: float = 1.0):
        if self.is_open and get_current_ms() >= self.wait + 83.33:
            self._draw_open(x, y, inner_fade_override, is_ura)
        else:
            self._draw_closed(x, y, outer_fade_override)

class BackBox(BaseBox):
    COLOR = (170, 115, 35)
    def __init__(self, name: str):
        super().__init__(name, BackBox.COLOR, BackBox.COLOR, TextureIndex.BLANK)
        self.yellow_box = None

    def load_text(self):
        super().load_text()
        self.text_loaded = True

    def update(self, current_time: float, is_diff_select: bool):
        super().update(current_time, is_diff_select)
        is_open_prev = self.is_open
        self.move_box(current_time)
        self.is_open = self.position == BOX_CENTER

        if self.yellow_box is not None:
            self.yellow_box.update(is_diff_select)

        if not is_open_prev and self.is_open:
            self.yellow_box = YellowBox(True)
            self.yellow_box.create_anim()
            self.wait = current_time

    def _draw_closed(self, x: float, y: float, outer_fade_override: float):
        super()._draw_closed(x, y, outer_fade_override)
        tex.draw_texture('box', 'back_text', x=x, y=y, fade=outer_fade_override)

    def _draw_open(self, x: float, y: float, fade_override: Optional[float] = None, is_ura: bool = False):
        if self.yellow_box is not None:
            self.yellow_box.draw(self, fade_override, is_ura, self.name)

class SongBox(BaseBox):
    def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, tja: TJAParser):
        super().__init__(name, back_color, fore_color, texture_index)
        self.scores = dict()
        self.hash = dict()
        self.score_history = None
        self.history_wait = 0
        self.tja = tja
        self.is_favorite = False
        self.yellow_box = None

    def load_text(self):
        super().load_text()
        self.text_loaded = True

    def get_scores(self):
        with sqlite3.connect(global_data.score_db) as con:
            cursor = con.cursor()
            # Batch database query for all diffs at once
            if self.tja.metadata.course_data:
                hash_values = [self.hash[diff] for diff in self.tja.metadata.course_data if diff in self.hash]
                placeholders = ','.join('?' * len(hash_values))

                batch_query = f"""
                    SELECT hash, score, good, ok, bad, drumroll, clear
                    FROM Scores
                    WHERE hash IN ({placeholders})
                """
                cursor.execute(batch_query, hash_values)

                hash_to_score = {row[0]: row[1:] for row in cursor.fetchall()}

                for diff in self.tja.metadata.course_data:
                    if diff not in self.hash:
                        continue
                    diff_hash = self.hash[diff]
                    self.scores[diff] = hash_to_score.get(diff_hash)
                self.score_history = None

    def update(self, current_time: float, is_diff_select: bool):
        super().update(current_time, is_diff_select)
        is_open_prev = self.is_open
        self.move_box(current_time)
        self.is_open = self.position == BOX_CENTER

        if self.yellow_box is not None:
            self.yellow_box.update(is_diff_select)

        if self.history_wait == 0:
            self.history_wait = current_time

        if self.score_history is None and {k: v for k, v in self.scores.items() if v is not None}:
            self.score_history = ScoreHistory(self.scores, current_time)

        if not is_open_prev and self.is_open:
            self.yellow_box = YellowBox(False, tja=self.tja)
            self.yellow_box.create_anim()
            self.wait = current_time
            if current_time >= self.history_wait + 3000:
                self.history_wait = current_time

        if self.score_history is not None:
            self.score_history.update(current_time)

    def _draw_closed(self, x: float, y: float, outer_fade_override: float):
        super()._draw_closed(x, y, outer_fade_override)

        self.name.draw(outline_color=self.fore_color, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+tex.skin_config["song_box_name"].y, y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override)

        if self.tja.ex_data.new:
            tex.draw_texture('yellow_box', 'ex_data_new_song_balloon', x=x, y=y, fade=outer_fade_override)
        valid_scores = {k: v for k, v in self.scores.items() if v is not None}
        if valid_scores:
            highest_key = max(valid_scores.keys())
            score = self.scores[highest_key]
            if score and score[5] == Crown.DFC:
                tex.draw_texture('yellow_box', 'crown_dfc', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override)
            elif score and score[5] == Crown.FC:
                tex.draw_texture('yellow_box', 'crown_fc', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override)
            elif score and score[5] >= Crown.CLEAR:
                tex.draw_texture('yellow_box', 'crown_clear', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override)

    def _draw_open(self, x: float, y: float, fade_override=None, is_ura=False):
        if self.yellow_box is not None:
            self.yellow_box.draw(self, fade_override, is_ura, self.name)

    def draw_score_history(self):
        if self.is_open and get_current_ms() >= self.wait + 83.33:
            if self.score_history is not None and get_current_ms() >= self.history_wait + 3000:
                self.score_history.draw()

class FolderBox(BaseBox):
    def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, genre_index: GenreIndex, tja_count: int = 0, box_texture: Optional[str] = None):
        super().__init__(name, back_color, fore_color, texture_index)
        self.box_texture_path = Path(box_texture) if box_texture else None
        self.is_back = self.back_color == BackBox.COLOR
        self.tja_count = tja_count
        self.crown = dict()
        self.genre_index = genre_index

    def load_text(self):
        super().load_text()
        self.hori_name = OutlinedText(self.text_name, tex.skin_config['song_hori_name'].font_size, ray.WHITE, outline_thickness=5)
        self.box_texture = ray.load_texture(str(self.box_texture_path)) if self.box_texture_path and self.box_texture_path.exists() else None
        if self.box_texture is not None:
            ray.gen_texture_mipmaps(self.box_texture)
            ray.set_texture_filter(self.box_texture, ray.TextureFilter.TEXTURE_FILTER_TRILINEAR)
        self.tja_count_text = OutlinedText(str(self.tja_count), tex.skin_config['song_tja_count'].font_size, ray.WHITE, outline_thickness=5)
        self.text_loaded = True

    def update(self, current_time: float, is_diff_select: bool):
        super().update(current_time, is_diff_select)
        is_open_prev = self.is_open
        self.move_box(current_time)
        self.is_open = self.position == BOX_CENTER

        if not is_open_prev and self.is_open:
            self.open_anim.start()
            self.open_fade.start()
            self.wait = current_time
            if self.back_color != BackBox.COLOR and not audio.is_sound_playing('voice_enter'):
                audio.play_sound(f'genre_voice_{self.genre_index}', 'voice')
        elif not self.is_open and is_open_prev and self.back_color != BackBox.COLOR and audio.is_sound_playing(f'genre_voice_{self.genre_index}'):
            audio.stop_sound(f'genre_voice_{self.genre_index}')

    def _draw_closed(self, x: float, y: float, outer_fade_override: float):
        super()._draw_closed(x, y, outer_fade_override)
        if self.shader is not None and self.texture_index == TextureIndex.BLANK:
            ray.begin_shader_mode(self.shader)
        tex.draw_texture('box', 'folder_clip', frame=self.texture_index, x=x - ((1 * tex.screen_scale)), y=y, fade=outer_fade_override)
        if self.shader is not None and self.texture_index == TextureIndex.BLANK:
            ray.end_shader_mode()

        self.name.draw(outline_color=self.fore_color, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+tex.skin_config["song_box_name"].y, y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override)

        if self.crown: #Folder lamp
            highest_crown = max(self.crown)
            if self.crown[highest_crown] == Crown.DFC:
                tex.draw_texture('yellow_box', 'crown_dfc', x=x, y=y, frame=min(Difficulty.URA, highest_crown), fade=outer_fade_override)
            elif self.crown[highest_crown] == Crown.FC:
                tex.draw_texture('yellow_box', 'crown_fc', x=x, y=y, frame=min(Difficulty.URA, highest_crown), fade=outer_fade_override)
            else:
                tex.draw_texture('yellow_box', 'crown_clear', x=x, y=y, frame=min(Difficulty.URA, highest_crown), fade=outer_fade_override)

    def _draw_open(self, x: float, y: float, fade_override: Optional[float], is_ura: bool):
        color = ray.WHITE
        if fade_override is not None:
            color = ray.fade(ray.WHITE, fade_override)
        if not self.is_back and self.open_anim.attribute >= (100 * tex.screen_scale):
            if self.shader is not None and self.texture_index == TextureIndex.BLANK:
                ray.begin_shader_mode(self.shader)
            tex.draw_texture('box', 'folder_top_edge', x=x, y=y - self.open_anim.attribute, color=color, mirror='horizontal', frame=self.texture_index)
            tex.draw_texture('box', 'folder_top', x=x, y=y - self.open_anim.attribute, color=color, frame=self.texture_index)
            tex.draw_texture('box', 'folder_top_edge', x=x+tex.skin_config["song_folder_top"].x, y=y - self.open_anim.attribute, color=color, frame=self.texture_index)
            dest_width = min(tex.skin_config["song_hori_name"].width, self.hori_name.texture.width)
            self.hori_name.draw(outline_color=ray.BLACK, x=(x + tex.skin_config["song_hori_name"].x) - (dest_width//2), y=y + tex.skin_config["song_hori_name"].y - self.open_anim.attribute, x2=dest_width-self.hori_name.texture.width, color=color)
            if self.shader is not None and self.texture_index == TextureIndex.BLANK:
                ray.end_shader_mode()

        if self.shader is not None and self.texture_index == TextureIndex.BLANK:
            ray.begin_shader_mode(self.shader)
        tex.draw_texture('box', 'folder_texture_left', frame=self.texture_index, x=x - self.open_anim.attribute)
        offset = 1 * tex.screen_scale if self.texture_index == 3 or self.texture_index >= 9 and self.texture_index not in {10,11,12} else 0
        tex.draw_texture('box', 'folder_texture', frame=self.texture_index, x=x - self.open_anim.attribute, y=offset, x2=(self.open_anim.attribute*2)+tex.skin_config["song_box_bg"].width)
        tex.draw_texture('box', 'folder_texture_right', frame=self.texture_index, x=x + self.open_anim.attribute)
        if self.shader is not None and self.texture_index == TextureIndex.BLANK:
            ray.end_shader_mode()

        if self.texture_index == TextureIndex.DEFAULT:
            tex.draw_texture('box', 'genre_overlay_large', x=x, y=y, color=color)
        if self.genre_index == GenreIndex.DIFFICULTY:
            tex.draw_texture('box', 'diff_overlay_large', x=x, y=y, color=color)

        color = ray.WHITE
        if fade_override is not None:
            color = ray.fade(ray.WHITE, fade_override)
        if self.genre_index != GenreIndex.DIFFICULTY:
            tex.draw_texture('yellow_box', 'song_count_back', color=color, fade=0.5)
            tex.draw_texture('yellow_box', 'song_count_num', color=color)
            tex.draw_texture('yellow_box', 'song_count_songs', color=color)
            dest_width = min(tex.skin_config["song_tja_count"].width, self.tja_count_text.texture.width)
            self.tja_count_text.draw(outline_color=ray.BLACK, x=tex.skin_config["song_tja_count"].x - (dest_width//2), y=tex.skin_config["song_tja_count"].y, x2=dest_width-self.tja_count_text.texture.width, color=color)
        if self.texture_index != TextureIndex.DEFAULT:
            tex.draw_texture('box', 'folder_graphic', color=color, frame=self.genre_index)
            tex.draw_texture('box', 'folder_text', color=color, frame=self.genre_index)
        elif self.box_texture is not None:
            scaled_width = self.box_texture.width * tex.screen_scale
            scaled_height = self.box_texture.height * tex.screen_scale
            x = int((x + tex.skin_config["box_texture"].x) - (scaled_width // 2))
            y = int((y + tex.skin_config["box_texture"].y) - (scaled_height // 2))
            src = ray.Rectangle(0, 0, self.box_texture.width, self.box_texture.height)
            dest = ray.Rectangle(x, y, scaled_width, scaled_height)
            ray.draw_texture_pro(self.box_texture, src, dest, ray.Vector2(0, 0), 0, color)

class YellowBox:
    """A song box when it is opened."""
    def __init__(self, is_back: bool, tja: Optional[TJAParser] = None, is_dan: bool = False):
        self.is_diff_select = False
        self.is_back = is_back
        self.tja = tja
        if self.tja is not None:
            subtitle_text = self.tja.metadata.subtitle.get(global_data.config['general']['language'], self.tja.metadata.subtitle.get('en', ''))
            font_size = tex.skin_config["yb_subtitle"].font_size if len(subtitle_text) < 30 else tex.skin_config["yb_subtitle"].font_size - int(10 * tex.screen_scale)
            self.subtitle = OutlinedText(subtitle_text, font_size, ray.WHITE, outline_thickness=5, vertical=True)
        else:
            self.subtitle = None
        self.is_dan = is_dan

        self.left_out = tex.get_animation(9)
        self.right_out = tex.get_animation(10)
        self.center_out = tex.get_animation(11)
        self.fade = tex.get_animation(12)

        self.left_out.reset()
        self.right_out.reset()
        self.center_out.reset()
        self.fade.reset()

        self.left_out_2 = tex.get_animation(13)
        self.right_out_2 = tex.get_animation(14)
        self.center_out_2 = tex.get_animation(15)
        self.top_y_out = tex.get_animation(16)
        self.center_h_out = tex.get_animation(17)
        self.fade_in = tex.get_animation(18)

        self.right_out_2.reset()
        self.top_y_out.reset()
        self.center_h_out.reset()

        self.right_x = self.right_out.attribute
        self.left_x = self.left_out.attribute
        self.center_width = self.center_out.attribute
        self.top_y = self.top_y_out.attribute
        self.center_height = self.center_h_out.attribute
        self.bottom_y = tex.textures['yellow_box']['yellow_box_bottom_right'].y[0]
        self.edge_height = tex.textures['yellow_box']['yellow_box_bottom_right'].height

    def create_anim(self):
        self.right_out_2.reset()
        self.top_y_out.reset()
        self.center_h_out.reset()
        self.left_out.start()
        self.right_out.start()
        self.center_out.start()
        self.fade.start()

    def create_anim_2(self):
        self.left_out_2.start()
        self.right_out_2.start()
        self.center_out_2.start()
        self.top_y_out.start()
        self.center_h_out.start()
        self.fade_in.start()

    def update(self, is_diff_select: bool):
        current_time = get_current_ms()
        self.left_out.update(current_time)
        self.right_out.update(current_time)
        self.center_out.update(current_time)
        self.fade.update(current_time)
        self.fade_in.update(current_time)
        self.left_out_2.update(current_time)
        self.right_out_2.update(current_time)
        self.center_out_2.update(current_time)
        self.top_y_out.update(current_time)
        self.center_h_out.update(current_time)
        if is_diff_select and not self.is_diff_select:
            self.create_anim_2()
        if self.is_diff_select:
            self.right_x = self.right_out_2.attribute
            self.left_x = self.left_out_2.attribute
            self.top_y = self.top_y_out.attribute
            self.center_width = self.center_out_2.attribute
            self.center_height = self.center_h_out.attribute
        else:
            self.right_x = self.right_out.attribute
            self.left_x = self.left_out.attribute
            self.center_width = self.center_out.attribute
            self.top_y = self.top_y_out.attribute
            self.center_height = self.center_h_out.attribute
        self.is_diff_select = is_diff_select

    def _draw_tja_data(self, song_box: SongBox, color: ray.Color, fade: float):
        if not self.tja:
            return
        offset = tex.skin_config['yb_diff_offset'].x
        for diff in self.tja.metadata.course_data:
            if diff >= Difficulty.URA:
                continue
            if diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] == Crown.DFC:
                tex.draw_texture('yellow_box', 's_crown_dfc', x=(diff*offset), color=color)
            elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] == Crown.FC:
                tex.draw_texture('yellow_box', 's_crown_fc', x=(diff*offset), color=color)
            elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] >= Crown.CLEAR:
                tex.draw_texture('yellow_box', 's_crown_clear', x=(diff*offset), color=color)
            tex.draw_texture('yellow_box', 's_crown_outline', x=(diff*offset), fade=min(fade, 0.25))

        if self.tja.ex_data.new_audio:
            tex.draw_texture('yellow_box', 'ex_data_new_audio', color=color)
        elif self.tja.ex_data.old_audio:
            tex.draw_texture('yellow_box', 'ex_data_old_audio', color=color)
        elif self.tja.ex_data.limited_time:
            tex.draw_texture('yellow_box', 'ex_data_limited_time', color=color)
        elif self.tja.ex_data.new:
            tex.draw_texture('yellow_box', 'ex_data_new_song', color=color)
        if song_box.is_favorite:
            tex.draw_texture('yellow_box', f'favorite_{global_data.player_num}p', color=color)

        for i in range(4):
            tex.draw_texture('yellow_box', 'difficulty_bar', frame=i, x=(i*offset), color=color)
            if i not in self.tja.metadata.course_data:
                tex.draw_texture('yellow_box', 'difficulty_bar_shadow', frame=i, x=(i*offset), fade=min(fade, 0.25))

        for diff in self.tja.metadata.course_data:
            if diff >= Difficulty.URA:
                continue
            for j in range(self.tja.metadata.course_data[diff].level):
                tex.draw_texture('yellow_box', 'star', x=(diff*offset), y=(j*tex.skin_config['yb_diff_offset'].y), color=color)
            if self.tja.metadata.course_data[diff].is_branching and (get_current_ms() // 1000) % 2 == 0:
                tex.draw_texture('yellow_box', 'branch_indicator', x=(diff*offset), color=color)

    def _draw_tja_data_diff(self, is_ura: bool, song_box: SongBox):
        if not self.tja:
            return
        tex.draw_texture('diff_select', 'back', fade=self.fade_in.attribute)
        tex.draw_texture('diff_select', 'option', fade=self.fade_in.attribute)
        tex.draw_texture('diff_select', 'neiro', fade=self.fade_in.attribute)

        offset_x = tex.skin_config['yb_diff_offset_diff_select'].x
        offset_y = tex.skin_config['yb_diff_offset_diff_select'].y
        for diff in self.tja.metadata.course_data:
            if diff >= Difficulty.URA:
                continue
            elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] == Crown.DFC:
                tex.draw_texture('yellow_box', 's_crown_dfc', x=(diff*offset_x)+tex.skin_config['yb_diff_offset_crown'].x, y=offset_y, fade=self.fade_in.attribute)
            elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] == Crown.FC:
                tex.draw_texture('yellow_box', 's_crown_fc', x=(diff*offset_x)+tex.skin_config['yb_diff_offset_crown'].x, y=offset_y, fade=self.fade_in.attribute)
            elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] >= Crown.CLEAR:
                tex.draw_texture('yellow_box', 's_crown_clear', x=(diff*offset_x)+tex.skin_config['yb_diff_offset_crown'].x, y=offset_y, fade=self.fade_in.attribute)
            tex.draw_texture('yellow_box', 's_crown_outline', x=(diff*offset_x)+tex.skin_config['yb_diff_offset_crown'].x, y=offset_y, fade=min(self.fade_in.attribute, 0.25))

        for i in range(4):
            if i == Difficulty.ONI and is_ura:
                tex.draw_texture('diff_select', 'diff_tower', frame=4, x=(i*offset_x), fade=self.fade_in.attribute)
                tex.draw_texture('diff_select', 'ura_oni_plate', fade=self.fade_in.attribute)
            else:
                tex.draw_texture('diff_select', 'diff_tower', frame=i, x=(i*offset_x), fade=self.fade_in.attribute)
            if i not in self.tja.metadata.course_data:
                tex.draw_texture('diff_select', 'diff_tower_shadow', frame=i, x=(i*offset_x), fade=min(self.fade_in.attribute, 0.25))

        for course in self.tja.metadata.course_data:
            if (course == Difficulty.URA and not is_ura) or (course == Difficulty.ONI and is_ura):
                continue
            for j in range(self.tja.metadata.course_data[course].level):
                tex.draw_texture('yellow_box', 'star_ura', x=min(course, Difficulty.ONI)*offset_x, y=(j*tex.skin_config["yb_diff_offset_crown"].y), fade=self.fade_in.attribute)
            if self.tja.metadata.course_data[course].is_branching and (get_current_ms() // 1000) % 2 == 0:
                if course == Difficulty.URA:
                    name = 'branch_indicator_ura'
                else:
                    name = 'branch_indicator_diff'
                tex.draw_texture('yellow_box', name, x=min(course, Difficulty.ONI)*offset_x, fade=self.fade_in.attribute)

    def _draw_text(self, song_box, name: OutlinedText):
        if not isinstance(self.right_out, MoveAnimation):
            return
        if not isinstance(self.right_out_2, MoveAnimation):
            return
        if not isinstance(self.top_y_out, MoveAnimation):
            return
        x = song_box.position + (self.right_out.attribute*0.85 - (self.right_out.start_position*0.85)) + self.right_out_2.attribute - self.right_out_2.start_position
        if self.is_back:
            tex.draw_texture('box', 'back_text_highlight', x=x)
        else:
            texture = name.texture
            name.draw(outline_color=ray.BLACK, x=x + tex.skin_config["yb_name"].x, y=tex.skin_config["yb_name"].y + self.top_y_out.attribute, y2=min(texture.height, tex.skin_config["yb_name"].height)-texture.height, color=ray.WHITE)
        if self.subtitle is not None:
            texture = self.subtitle.texture
            y = self.bottom_y - min(texture.height, tex.skin_config["yb_subtitle"].height) + tex.skin_config["yb_subtitle"].y + self.top_y_out.attribute - self.top_y_out.start_position
            self.subtitle.draw(outline_color=ray.BLACK, x=x+tex.skin_config["yb_subtitle"].x, y=y, y2=min(texture.height, tex.skin_config["yb_subtitle"].height)-texture.height)

    def _draw_yellow_box(self):
        tex.draw_texture('yellow_box', 'yellow_box_bottom_right', x=self.right_x)
        tex.draw_texture('yellow_box', 'yellow_box_bottom_left', x=self.left_x, y=self.bottom_y)
        tex.draw_texture('yellow_box', 'yellow_box_top_right', x=self.right_x, y=self.top_y)
        tex.draw_texture('yellow_box', 'yellow_box_top_left', x=self.left_x, y=self.top_y)
        tex.draw_texture('yellow_box', 'yellow_box_bottom', x=self.left_x + self.edge_height, y=self.bottom_y, x2=self.center_width)
        tex.draw_texture('yellow_box', 'yellow_box_right', x=self.right_x, y=self.top_y + self.edge_height, y2=self.center_height)
        tex.draw_texture('yellow_box', 'yellow_box_left', x=self.left_x, y=self.top_y + self.edge_height, y2=self.center_height)
        tex.draw_texture('yellow_box', 'yellow_box_top', x=self.left_x + self.edge_height, y=self.top_y, x2=self.center_width)
        tex.draw_texture('yellow_box', 'yellow_box_center', x=self.left_x + self.edge_height, y=self.top_y + self.edge_height, x2=self.center_width, y2=self.center_height)

    def draw(self, song_box: Optional[SongBox | BackBox], fade_override: Optional[float], is_ura: bool, name: OutlinedText):
        self._draw_yellow_box()
        fade = self.fade.attribute
        if fade_override is not None:
            fade = min(self.fade.attribute, fade_override)
        if self.is_dan:
            return
        if self.is_back:
            tex.draw_texture('box', 'back_graphic', fade=fade)
            return
        if self.is_diff_select and isinstance(song_box, SongBox):
            self._draw_tja_data_diff(is_ura, song_box)
        elif isinstance(song_box, SongBox):
            self._draw_tja_data(song_box, ray.fade(ray.WHITE, fade), fade)

        self._draw_text(song_box, name)

class DanBox(BaseBox):
    def __init__(self, name, color: TextureIndex, songs: list[tuple[TJAParser, int, int, int]], exams: list['Exam']):
        super().__init__(name, None, None, color)
        self.songs = songs
        self.exams = exams
        self.song_text: list[tuple[OutlinedText, OutlinedText]] = []
        self.total_notes = 0
        self.yellow_box = None
        for song, genre_index, difficulty, level in self.songs:
            notes, branch_m, branch_e, branch_n = song.notes_to_position(difficulty)
            self.total_notes += sum(1 for note in notes.play_notes if note.type < 5)
            for branch in branch_m:
                self.total_notes += sum(1 for note in branch.play_notes if note.type < 5)
            for branch in branch_e:
                self.total_notes += sum(1 for note in branch.play_notes if note.type < 5)
            for branch in branch_n:
                self.total_notes += sum(1 for note in branch.play_notes if note.type < 5)

    def load_text(self):
        super().load_text()
        self.hori_name = OutlinedText(self.text_name, tex.skin_config["dan_title"].font_size, ray.WHITE)
        for song, genre, difficulty, level in self.songs:
            title = song.metadata.title.get(global_data.config["general"]["language"], song.metadata.title["en"])
            subtitle = song.metadata.subtitle.get(global_data.config["general"]["language"], "")
            title_text = OutlinedText(title, tex.skin_config["dan_title"].font_size, ray.WHITE, vertical=True)
            font_size = tex.skin_config["dan_subtitle"].font_size if len(subtitle) < 30 else tex.skin_config["dan_subtitle"].font_size - int(10 * tex.screen_scale)
            subtitle_text = OutlinedText(subtitle, font_size, ray.WHITE, vertical=True)
            self.song_text.append((title_text, subtitle_text))
        self.text_loaded = True

    def update(self, current_time: float, is_diff_select: bool):
        super().update(current_time, is_diff_select)
        is_open_prev = self.is_open
        self.move_box(current_time)
        self.is_open = self.position == BOX_CENTER
        if not is_open_prev and self.is_open:
            self.yellow_box = YellowBox(False, is_dan=True)
            self.yellow_box.create_anim()

        if self.yellow_box is not None:
            self.yellow_box.update(True)

    def _draw_exam_box(self):
        tex.draw_texture('yellow_box', 'exam_box_bottom_right')
        tex.draw_texture('yellow_box', 'exam_box_bottom_left')
        tex.draw_texture('yellow_box', 'exam_box_top_right')
        tex.draw_texture('yellow_box', 'exam_box_top_left')
        tex.draw_texture('yellow_box', 'exam_box_bottom')
        tex.draw_texture('yellow_box', 'exam_box_right')
        tex.draw_texture('yellow_box', 'exam_box_left')
        tex.draw_texture('yellow_box', 'exam_box_top')
        tex.draw_texture('yellow_box', 'exam_box_center')
        tex.draw_texture('yellow_box', 'exam_header')

        offset = tex.skin_config["exam_box_offset"].y
        for i, exam in enumerate(self.exams):
            tex.draw_texture('yellow_box', 'judge_box', y=(i*offset))
            tex.draw_texture('yellow_box', 'exam_' + exam.type, y=(i*offset))
            counter = str(exam.red)
            margin = tex.skin_config["exam_counter_margin"].x
            if exam.type == 'gauge':
                tex.draw_texture('yellow_box', 'exam_percent', y=(i*offset))
                x_offset = tex.skin_config["exam_gauge_offset"].x
            else:
                x_offset = 0
            for j in range(len(counter)):
                tex.draw_texture('yellow_box', 'judge_num', frame=int(counter[j]), x=x_offset-(len(counter) - j) * margin, y=(i*offset))

            if exam.range == 'more':
                tex.draw_texture('yellow_box', 'exam_more', x=(x_offset*-1.7), y=(i*offset))
            elif exam.range == 'less':
                tex.draw_texture('yellow_box', 'exam_less', x=(x_offset*-1.7), y=(i*offset))

    def _draw_closed(self, x: float, y: float, outer_fade_override: float):
        tex.draw_texture('box', 'folder', frame=self.texture_index, x=x, fade=outer_fade_override)
        if self.name is not None:
            self.name.draw(outline_color=ray.BLACK, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+(tex.skin_config["song_box_name"].height//2), y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override)

    def _draw_open(self, x: float, y: float, fade_override: Optional[float], is_ura: bool):
        if fade_override is not None:
            fade = fade_override
        else:
            fade = 1.0
        if self.yellow_box is not None:
            self.yellow_box.draw(None, None, False, self.name)
            for i, song in enumerate(self.song_text):
                title, subtitle = song
                x = i * tex.skin_config["dan_yellow_box_offset"].x
                tex.draw_texture('yellow_box', 'genre_banner', x=x, frame=self.songs[i][1], fade=fade)
                tex.draw_texture('yellow_box', 'difficulty', x=x, frame=self.songs[i][2], fade=fade)
                tex.draw_texture('yellow_box', 'difficulty_x', x=x, fade=fade)
                tex.draw_texture('yellow_box', 'difficulty_star', x=x, fade=fade)
                level = self.songs[i][0].metadata.course_data[self.songs[i][2]].level
                counter = str(level)
                margin = tex.skin_config["dan_level_counter_margin"].x
                total_width = len(counter) * margin
                for i in range(len(counter)):
                    tex.draw_texture('yellow_box', 'difficulty_num', frame=int(counter[i]), x=x-(total_width // 2) + (i * margin), fade=fade)

                title_data = tex.skin_config["dan_title"]
                subtitle_data = tex.skin_config["dan_subtitle"]
                title.draw(outline_color=ray.BLACK, x=title_data.x+x, y=title_data.y, y2=min(title.texture.height, title_data.height)-title.texture.height, fade=fade)
                subtitle.draw(outline_color=ray.BLACK, x=subtitle_data.x+x, y=subtitle_data.y-min(subtitle.texture.height, subtitle_data.height), y2=min(subtitle.texture.height, subtitle_data.height)-subtitle.texture.height, fade=fade)

            tex.draw_texture('yellow_box', 'total_notes_bg', fade=fade)
            tex.draw_texture('yellow_box', 'total_notes', fade=fade)
            counter = str(self.total_notes)
            for i in range(len(counter)):
                tex.draw_texture('yellow_box', 'total_notes_counter', frame=int(counter[i]), x=(i * tex.skin_config["total_notes_counter_margin"].x), fade=fade)

            tex.draw_texture('yellow_box', 'frame', frame=self.texture_index, fade=fade)
            if self.hori_name is not None:
                self.hori_name.draw(outline_color=ray.BLACK, x=tex.skin_config["dan_hori_name"].x - (self.hori_name.texture.width//2), y=tex.skin_config["dan_hori_name"].y, x2=min(self.hori_name.texture.width, tex.skin_config["dan_hori_name"].width)-self.hori_name.texture.width, fade=fade)

            self._draw_exam_box()

class GenreBG:
    """The background for a genre box."""
    def __init__(self, start_box: BaseBox, end_box: BaseBox, title: OutlinedText, diff_sort: Optional[int]):
        self.title = title
        self.start_box = start_box
        self.end_box = end_box
        self.start_position = start_box.position
        self.end_position_final = end_box.position
        self.fade_in = Animation.create_fade(133, initial_opacity=0.0, final_opacity=1.0, ease_in='quadratic', delay=50)
        self.move = Animation.create_move(316, delay=self.fade_in.duration/2, total_distance=abs(self.end_position_final - self.start_position), ease_in='quadratic')
        self.box_fade_in = Animation.create_fade(66.67*2, delay=self.move.duration, initial_opacity=0.0, final_opacity=1.0)
        self.fade_in.start()
        self.move.start()
        self.box_fade_in.start()
        self.end_position = self.start_position + self.move.attribute
        self.diff_num = diff_sort
        self.color = self.end_box.back_color
        self.shader = None
        self.shader_loaded = False

    def load_shader(self):
        if self.color is not None:
            self.shader = ray.load_shader('shader/dummy.vs', 'shader/colortransform.fs')
            source_rgb = (142, 212, 30)
            target_rgb = self.color
            source_color = ray.ffi.new('float[3]', [source_rgb[0]/255.0, source_rgb[1]/255.0, source_rgb[2]/255.0])
            target_color = ray.ffi.new('float[3]', [target_rgb[0]/255.0, target_rgb[1]/255.0, target_rgb[2]/255.0])
            source_loc = ray.get_shader_location(self.shader, 'sourceColor')
            target_loc = ray.get_shader_location(self.shader, 'targetColor')
            ray.set_shader_value(self.shader, source_loc, source_color, SHADER_UNIFORM_VEC3)
            ray.set_shader_value(self.shader, target_loc, target_color, SHADER_UNIFORM_VEC3)
        self.shader_loaded = True

    def update(self, current_ms):
        self.start_position = self.start_box.position
        self.end_position = self.start_position + self.move.attribute
        if self.move.is_finished:
            self.end_position = self.end_box.position
        self.box_fade_in.update(current_ms)
        self.fade_in.update(current_ms)
        self.move.update(current_ms)

    def draw(self, y):
        if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK:
            ray.begin_shader_mode(self.shader)
        offset = (tex.skin_config["genre_bg_offset"].x * -1) if self.start_box.is_open else 0

        tex.draw_texture('box', 'folder_background_edge', frame=self.end_box.texture_index, x=self.start_position+offset, y=y, mirror="horizontal", fade=self.fade_in.attribute)


        extra_distance = tex.skin_config["genre_bg_extra_distance"].x if self.end_box.is_open or (self.start_box.is_open and (844 * tex.screen_scale) <= self.end_position <= (1144 * tex.screen_scale)) else 0
        if self.start_position >= tex.skin_config["genre_bg_left_max"].x and self.end_position < self.start_position:
            x2 = self.start_position + tex.skin_config["genre_bg_offset_2"].x
            x = self.start_position+offset
        elif (self.start_position <= tex.skin_config["genre_bg_left_max"].x) and (self.end_position < self.start_position):
            x = 0
            x2 = tex.screen_width
        else:
            x2 = abs(self.end_position) - self.start_position + extra_distance + (-1 * tex.skin_config["genre_bg_left_max"].x + (1 * tex.screen_scale))
            x = self.start_position+offset
        tex.draw_texture('box', 'folder_background', x=x, y=y, x2=x2, frame=self.end_box.texture_index)


        if self.end_position < self.start_position and self.end_position >= tex.skin_config["genre_bg_left_max"].x:
            x2 = min(self.end_position+tex.skin_config["genre_bg_folder_background"].width, tex.screen_width) + extra_distance
            tex.draw_texture('box', 'folder_background', x=tex.skin_config["genre_bg_folder_background"].x, y=y, x2=x2, frame=self.end_box.texture_index)


        offset = tex.skin_config["genre_bg_offset"].x if self.end_box.is_open else 0
        tex.draw_texture('box', 'folder_background_edge', x=self.end_position+tex.skin_config["genre_bg_folder_edge"].x+offset, y=y, fade=self.fade_in.attribute, frame=self.end_box.texture_index)

        if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK:
            ray.end_shader_mode()
        if ((self.start_position <= BOX_CENTER and self.end_position >= BOX_CENTER) or
            ((self.start_position <= BOX_CENTER or self.end_position >= BOX_CENTER) and (self.start_position > self.end_position))):
            offset = tex.skin_config["genre_bg_offset_3"].x if self.diff_num is not None else 0
            dest_width = min(tex.skin_config["genre_bg_title"].width, self.title.texture.width)
            if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK:
                ray.begin_shader_mode(self.shader)
            tex.draw_texture('box', 'folder_background_folder', x=-((offset+dest_width)//2), y=y+tex.skin_config["genre_bg_folder_background_folder"].y, x2=dest_width+offset++tex.skin_config["genre_bg_folder_background_folder"].width, fade=self.fade_in.attribute, frame=self.end_box.texture_index)
            tex.draw_texture('box', 'folder_background_folder_edge', x=-((offset+dest_width)//2), y=y+tex.skin_config["genre_bg_folder_background_folder"].y, fade=self.fade_in.attribute, frame=self.end_box.texture_index, mirror="horizontal")
            tex.draw_texture('box', 'folder_background_folder_edge', x=((offset+dest_width)//2)+tex.skin_config["genre_bg_folder_background_folder"].x, y=y+tex.skin_config["genre_bg_folder_background_folder"].y, fade=self.fade_in.attribute, frame=self.end_box.texture_index)
            if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK:
                ray.end_shader_mode()
            if self.diff_num is not None:
                tex.draw_texture('diff_sort', 'star_num', frame=self.diff_num, x=(tex.skin_config["genre_bg_offset"].x * -1) + (dest_width//2), y=tex.skin_config["diff_sort_star_num"].y)
            self.title.draw(outline_color=ray.BLACK, x=(tex.screen_width//2) - (dest_width//2)-(offset//2), y=y+tex.skin_config["genre_bg_title"].y, x2=dest_width - self.title.texture.width, color=ray.fade(ray.WHITE, self.fade_in.attribute))

class ScoreHistory:
    """The score information that appears while hovering over a song"""
    def __init__(self, scores: dict[int, tuple[int, int, int, int]], current_ms):
        """
        Initialize the score history with the given scores and current time.

        Args:
            scores (dict[int, tuple[int, int, int, int]]): A dictionary of scores for each difficulty level.
            current_ms (int): The current time in milliseconds.
        """
        self.scores = {k: v for k, v in scores.items() if v is not None}
        self.difficulty_keys = list(self.scores.keys())
        self.curr_difficulty_index = 0
        self.curr_difficulty_index = (self.curr_difficulty_index + 1) % len(self.difficulty_keys)
        self.curr_difficulty = self.difficulty_keys[self.curr_difficulty_index]
        self.curr_score = self.scores[self.curr_difficulty][0]
        self.curr_score_su = self.scores[self.curr_difficulty][0]
        self.last_ms = current_ms
        self.long = True

    def update(self, current_ms):
        if current_ms >= self.last_ms + 1000:
            self.last_ms = current_ms
            self.curr_difficulty_index = (self.curr_difficulty_index + 1) % len(self.difficulty_keys)
            self.curr_difficulty = self.difficulty_keys[self.curr_difficulty_index]
            self.curr_score = self.scores[self.curr_difficulty][0]
            self.curr_score_su = self.scores[self.curr_difficulty][0]

    def draw_long(self):
        tex.draw_texture('leaderboard','background_2')
        tex.draw_texture('leaderboard','title', index=self.long)

        match global_data.config["general"]["score_method"]:
            case ScoreMethod.SHINUCHI:
                if self.curr_difficulty == Difficulty.URA:
                    tex.draw_texture('leaderboard', 'shinuchi_ura', index=self.long)
                else:
                    tex.draw_texture('leaderboard', 'shinuchi', index=self.long)
                tex.draw_texture('leaderboard', 'pts', color=ray.WHITE, index=self.long)
            case ScoreMethod.GEN3:
                    tex.draw_texture('leaderboard', 'normal', index=self.long)
                    tex.draw_texture('leaderboard', 'pts', color=ray.BLACK, index=self.long)

        tex.draw_texture('leaderboard', 'difficulty', frame=self.curr_difficulty, index=self.long)

        for i in range(4):
            tex.draw_texture('leaderboard', 'normal', index=self.long, y=tex.skin_config["score_info_bg_offset"].y+(i*tex.skin_config["score_info_bg_offset"].y))

        tex.draw_texture('leaderboard', 'judge_good')
        tex.draw_texture('leaderboard', 'judge_ok')
        tex.draw_texture('leaderboard', 'judge_bad')
        tex.draw_texture('leaderboard', 'judge_drumroll')

        for j, counter in enumerate(self.scores[self.curr_difficulty]):
            if j == Difficulty.TOWER:
                continue
            if counter is None:
                continue
            counter = str(counter)
            margin = tex.skin_config["score_info_counter_margin"].x
            for i in range(len(counter)):
                if j == 0:
                    match global_data.config["general"]["score_method"]:
                        case ScoreMethod.SHINUCHI:
                            tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-((len(counter) * tex.skin_config["score_info_counter_margin"].width) // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=ray.WHITE, index=self.long)
                        case ScoreMethod.GEN3:
                            tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-((len(counter) * tex.skin_config["score_info_counter_margin"].width) // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=ray.BLACK, index=self.long)
                else:
                    tex.draw_texture('leaderboard', 'judge_num', frame=int(counter[i]), x=-(len(counter) - i) * margin, y=j*tex.skin_config["score_info_bg_offset"].y)

    def draw(self):
        if self.long:
            self.draw_long()
            return
        tex.draw_texture('leaderboard','background')
        tex.draw_texture('leaderboard','title')

        if self.curr_difficulty == Difficulty.URA:
            tex.draw_texture('leaderboard', 'normal_ura')
            tex.draw_texture('leaderboard', 'shinuchi_ura')
        else:
            tex.draw_texture('leaderboard', 'normal')
            tex.draw_texture('leaderboard', 'shinuchi')

        color = ray.BLACK
        if self.curr_difficulty == Difficulty.URA:
            color = ray.WHITE
            tex.draw_texture('leaderboard','ura')

        tex.draw_texture('leaderboard', 'pts', color=color)
        tex.draw_texture('leaderboard', 'pts', y=tex.skin_config["score_info_bg_offset"].y)

        tex.draw_texture('leaderboard', 'difficulty', frame=self.curr_difficulty)

        counter = str(self.curr_score)
        total_width = len(counter) * tex.skin_config["score_info_counter_margin"].width
        for i in range(len(counter)):
            tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-(total_width // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=color)

        counter = str(self.curr_score_su)
        total_width = len(counter) * tex.skin_config["score_info_counter_margin"].width
        for i in range(len(counter)):
            tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-(total_width // 2) + (i * tex.skin_config["score_info_counter_margin"].width), y=tex.skin_config["score_info_bg_offset"].y, color=ray.WHITE)

def parse_hex_color(color) -> tuple[int, int, int]:
    """Parse hex color to RGB tuple"""
    color = color.lstrip('#')

    if len(color) == 3:
        color = ''.join([c*2 for c in color])

    res = tuple(int(color[i:i+2], 16) for i in (0, 2, 4))
    return (res[0], res[1], res[2])

def get_genre_index(genre_string: str) -> GenreIndex:
    genre_upper = genre_string.upper()
    for genre_index, genre_set in FileSystemItem.GENRE_MAP.items():
        if genre_upper in genre_set:
            return genre_index
    return GenreIndex.DEFAULT

DEFAULT_COLORS = {
    GenreIndex.JPOP: [(32, 160, 186), (0, 77, 104)],
    GenreIndex.ANIME: [(255, 152, 0), (156, 64, 2)],
    GenreIndex.VOCALOID: [None, (84, 101, 126)],
    GenreIndex.CHILDREN: [(255, 82, 134), (153, 4, 46)],
    GenreIndex.VARIETY: [(142, 212, 30), (60, 104, 0)],
    GenreIndex.CLASSICAL: [(209, 162, 19), (134, 88, 0)],
    GenreIndex.GAME: [(156, 117, 189), (79, 40, 134)],
    GenreIndex.NAMCO: [(255, 90, 19), (148, 24, 0)],
    GenreIndex.DEFAULT: [None, (101, 0, 82)],
    GenreIndex.RECOMMENDED: [None, (140, 39, 92)],
    GenreIndex.FAVORITE: [None, (151, 57, 30)],
    GenreIndex.RECENT: [None, (35, 123, 103)],
    GenreIndex.DAN: [(35, 102, 170), (25, 68, 137)],
    GenreIndex.DIFFICULTY: [(255, 85, 95), (157, 13, 31)]
}

def parse_box_def(path: Path):
    """Parse box.def file for directory metadata"""
    name = path.name
    genre = ''
    texture_index = TextureIndex.DEFAULT
    genre_index = GenreIndex.DEFAULT
    collection = None
    back_color = None
    fore_color = None
    encoding = test_encodings(path / "box.def")

    with open(path / "box.def", 'r', encoding=encoding) as box_def:
        for line in box_def:
            line = line.strip()
            if line.startswith("#GENRE:"):
                genre = line.split(":", 1)[1].strip()
                texture_index = FileSystemItem.TEXTURE_MAP.get(genre, texture_index)
                genre_index = get_genre_index(genre)
            elif line.startswith("#TITLE:"):
                name = line.split(":", 1)[1].strip()
            elif line.startswith("#TITLEJA:"):
                if global_data.config['general']['language'] == 'ja':
                    name = line.split(":", 1)[1].strip()
            elif line.startswith("#COLLECTION:"):
                collection = line.split(":", 1)[1].strip()
                texture_index = FileSystemItem.TEXTURE_MAP.get(collection, texture_index)
                genre_index = get_genre_index(collection)
            elif line.startswith("#BACKCOLOR:"):
                back_color = parse_hex_color(line.split(":", 1)[1].strip())
                texture_index = TextureIndex.BLANK
            elif line.startswith("#FORECOLOR:"):
                fore_color = parse_hex_color(line.split(":", 1)[1].strip())
            if name == '':
                if genre:
                    name = genre
                else:
                    name = path.name

    if back_color is None and fore_color is None and genre_index in DEFAULT_COLORS:
        back_color, fore_color = DEFAULT_COLORS[genre_index]
        if genre_index != GenreIndex.DEFAULT:
            texture_index = TextureIndex.BLANK

    return name, texture_index, genre_index, collection, back_color, fore_color

class FileSystemItem:
    TEXTURE_MAP = {
        'VOCALOID': TextureIndex.VOCALOID,
        'ボーカロイド': TextureIndex.VOCALOID,
        'RECOMMENDED': TextureIndex.RECOMMENDED,
        'FAVORITE': TextureIndex.FAVORITE,
        'RECENT': TextureIndex.RECENT,
    }
    GENRE_MAP = {
        GenreIndex.TUTORIAL: {"TUTORIAL"},
        GenreIndex.JPOP: {"J-POP"},
        GenreIndex.ANIME: {"ANIME", "アニメ"},
        GenreIndex.CHILDREN: {"CHILDREN", "どうよう"},
        GenreIndex.VOCALOID: {"VOCALOID", "ボーカロイド"},
        GenreIndex.VARIETY: {"VARIETY", "バラエティー", "バラエティ"},
        GenreIndex.CLASSICAL: {"CLASSICAL", "クラシック"},
        GenreIndex.GAME: {"GAME", "ゲームミュージック"},
        GenreIndex.NAMCO: {"NAMCO", "ナムコオリジナル"},
        GenreIndex.RECOMMENDED: {"RECOMMENDED"},
        GenreIndex.FAVORITE: {"FAVORITE"},
        GenreIndex.RECENT: {"RECENT"},
        GenreIndex.DAN: {"DAN", "段位道場"},
        GenreIndex.DIFFICULTY: {"DIFFICULTY"},
    }
    """Base class for files and directories in the navigation system"""
    def __init__(self, path: Path, name: str):
        self.path = path
        self.name = name

class Directory(FileSystemItem):
    """Represents a directory in the navigation system"""
    COLLECTIONS = [
        'NEW',
        'RECENT',
        'FAVORITE',
        'DIFFICULTY',
        'RECOMMENDED'
    ]
    def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, genre_index: GenreIndex, has_box_def=False, to_root=False, back=False, tja_count=0, box_texture=None, collection=None):
        super().__init__(path, name)
        self.has_box_def = has_box_def
        self.to_root = to_root
        self.back = back
        self.tja_count = tja_count
        self.collection = None
        if collection in Directory.COLLECTIONS:
            self.collection = collection

        if self.back:
            self.box = BackBox(name)
        else:
            self.box = FolderBox(name, back_color, fore_color, texture_index, genre_index, tja_count=tja_count, box_texture=box_texture)

class SongFile(FileSystemItem):
    """Represents a song file (TJA) in the navigation system"""
    def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex):
        super().__init__(path, name)
        self.is_recent = (datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)) <= timedelta(days=7)
        self.tja = TJAParser(path)
        if self.is_recent:
            self.tja.ex_data.new = True
        title = self.tja.metadata.title.get(global_data.config['general']['language'].lower(), self.tja.metadata.title['en'])
        self.hash = global_data.song_paths[path]
        self.box = SongBox(title, back_color, fore_color, texture_index, self.tja)
        self.box.hash = global_data.song_hashes[self.hash][0]["diff_hashes"]
        self.box.get_scores()

@dataclass
class Exam:
    type: str
    red: int
    gold: int
    range: str

class DanCourse(FileSystemItem):
    def __init__(self, path: Path, name: str):
        super().__init__(path, name)
        if name != "dan.json":
            logger.error(f"Invalid dan course file: {path}")
        with open(path, 'r', encoding='utf-8') as f:
            data = json.load(f)
            self.title = data["title"]
            self.color = data["color"]
            self.charts: list[tuple[TJAParser, int, int, int]] = []
            for chart in data["charts"]:
                hash = chart["hash"]
                chart_title = chart["title"]
                chart_subtitle = chart["subtitle"]
                difficulty = chart["difficulty"]
                if hash in global_data.song_hashes:
                    path = Path(global_data.song_hashes[hash][0]["file_path"])
                else:
                    for key, value in global_data.song_hashes.items():
                        for i in range(len(value)):
                            song = value[i]
                            if (song["title"]["en"].strip() == chart_title and
                                song["subtitle"]["en"].strip() == chart_subtitle.removeprefix('--') and
                                Path(song["file_path"]).exists()):
                                hash_val = key
                                path = Path(global_data.song_hashes[hash_val][i]["file_path"])
                                break
                if (path.parent.parent / "box.def").exists():
                    genre_index = parse_box_def(path.parent.parent)[2]
                else:
                    genre_index = GenreIndex.NAMCO
                tja = TJAParser(path)
                self.charts.append((tja, genre_index, difficulty, tja.metadata.course_data[difficulty].level))
            self.exams = []
            for exam in data["exams"]:
                self.exams.append(Exam(exam["type"], exam["value"][0], exam["value"][1], exam["range"]))

        self.box = DanBox(self.title, self.color, self.charts, self.exams)

class FileNavigator:
    """Manages navigation through pre-generated Directory and SongFile objects"""
    def __init__(self):

        # Pre-generated objects storage
        self.all_directories: dict[str, Directory] = {}  # path -> Directory
        self.all_song_files: dict[str, Union[SongFile, DanCourse]] = {}    # path -> SongFile
        self.directory_contents: dict[str, list[Union[Directory, SongFile]]] = {}  # path -> list of items

        # OPTION 2: Lazy crown calculation with caching
        self.directory_crowns: dict[str, dict] = dict()  # path -> crown list
        self.crown_cache_dirty: set[str] = set()  # directories that need crown recalculation

        # Navigation state - simplified without root-specific state
        self.current_dir = Path()  # Empty path represents virtual root
        self.items: list[Directory | SongFile] = []
        self.new_items: list[Directory | SongFile] = []
        self.favorite_folder: Optional[Directory] = None
        self.recent_folder: Optional[Directory] = None
        self.selected_index = 0
        self.diff_sort_diff = Difficulty.URA
        self.diff_sort_level = 10
        self.diff_sort_statistics = dict()
        self.history = []
        self.box_open = False
        self.genre_bg = None
        self.song_count = 0
        self.in_dan_select = False
        logger.info("FileNavigator initialized")

    def initialize(self, root_dirs: list[Path]):
        self.root_dirs = [Path(p) if not isinstance(p, Path) else p for p in root_dirs]
        self._generate_all_objects()
        self._create_virtual_root()
        self.load_current_directory()
        logger.info(f"FileNavigator initialized with root_dirs: {self.root_dirs}")

    def _create_virtual_root(self):
        """Create a virtual root directory containing all root directories"""
        virtual_root_items = []

        for root_path in self.root_dirs:
            if not root_path.exists():
                continue

            root_key = str(root_path)
            if root_key in self.all_directories:
                # Root has box.def, add the directory itself
                virtual_root_items.append(self.all_directories[root_key])
            else:
                # Root doesn't have box.def, add its immediate children with box.def
                for child_path in sorted(root_path.iterdir()):
                    if child_path.is_dir():
                        child_key = str(child_path)
                        if child_key in self.all_directories:
                            virtual_root_items.append(self.all_directories[child_key])

                # Also add direct TJA files from root
                all_tja_files = self._find_tja_files_recursive(root_path)
                for tja_path in sorted(all_tja_files):
                    song_key = str(tja_path)
                    if song_key in self.all_song_files:
                        virtual_root_items.append(self.all_song_files[song_key])

        # Store virtual root contents (empty path key represents root)
        self.directory_contents["."] = virtual_root_items

    def _generate_all_objects(self):
        """Generate all Directory and SongFile objects in advance"""
        logging.info("Generating all Directory and SongFile objects...")

        # Generate objects for each root directory
        for root_path in self.root_dirs:
            if not root_path.exists():
                logging.warning(f"Root directory does not exist: {root_path}")
                continue

            self._generate_objects_recursive(root_path)

        if self.favorite_folder is not None and self.favorite_folder.path.exists():
            song_list = self._read_song_list(self.favorite_folder.path)
            for song_obj in song_list:
                if str(song_obj) in self.all_song_files:
                    box = self.all_song_files[str(song_obj)].box
                    if isinstance(box, DanBox):
                        logger.warning(f"Cannot favorite DanCourse: {song_obj}")
                    else:
                        box.is_favorite = True

        logging.info(f"Object generation complete. "
                    f"Directories: {len(self.all_directories)}, "
                    f"Songs: {len(self.all_song_files)}")

    def _generate_objects_recursive(self, dir_path: Path):
        """Recursively generate Directory and SongFile objects for a directory"""
        if not dir_path.is_dir():
            return

        dir_key = str(dir_path)

        # Check for box.def
        has_box_def = (dir_path / "box.def").exists()

        # Only create Directory objects for directories with box.def
        if has_box_def:
            # Parse box.def if it exists
            name = dir_path.name if dir_path.name else str(dir_path)
            box_texture = None
            collection = None
            back_color = None
            fore_color = None

            name, texture_index, genre_index, collection, back_color, fore_color = parse_box_def(dir_path)
            box_png_path = dir_path / "box.png"
            if box_png_path.exists():
                box_texture = str(box_png_path)

            # Count TJA files for this directory
            tja_count = self._count_tja_files(dir_path)
            if collection == Directory.COLLECTIONS[4]:
                tja_count = 10
            elif collection == Directory.COLLECTIONS[0]:
                tja_count = len(self.new_items)

            # Create Directory object
            directory_obj = Directory(
                dir_path, name, back_color, fore_color, texture_index, genre_index,
                has_box_def=has_box_def,
                tja_count=tja_count,
                box_texture=box_texture,
                collection=collection,
            )
            if directory_obj.collection == Directory.COLLECTIONS[2]:
                self.favorite_folder = directory_obj
            elif directory_obj.collection == Directory.COLLECTIONS[1]:
                self.recent_folder = directory_obj
            self.all_directories[dir_key] = directory_obj

            # Generate content list for this directory
            content_items = []

            # Add child directories that have box.def
            child_dirs = []
            for item_path in dir_path.iterdir():
                if item_path.is_dir():
                    child_has_box_def = (item_path / "box.def").exists()
                    if child_has_box_def:
                        child_dirs.append(item_path)
                        # Recursively generate objects for child directory
                        self._generate_objects_recursive(item_path)

            # Sort and add child directories
            for child_path in sorted(child_dirs):
                child_key = str(child_path)
                if child_key in self.all_directories:
                    content_items.append(self.all_directories[child_key])

            # Get TJA files for this directory
            tja_files = self._get_tja_files_for_directory(dir_path)

            # Create SongFile objects
            for tja_path in sorted(tja_files):
                song_key = str(tja_path)
                if song_key not in self.all_song_files and tja_path.name == "dan.json":
                    try:
                        song_obj = DanCourse(tja_path, tja_path.name)
                        self.all_song_files[song_key] = song_obj
                    except Exception as e:
                        logger.error(f"Error creating DanCourse object for {tja_path}: {e}")
                elif song_key not in self.all_song_files and tja_path in global_data.song_paths:
                    song_obj = SongFile(tja_path, tja_path.name, back_color, fore_color, texture_index)
                    song_obj.box.get_scores()
                    for course in song_obj.tja.metadata.course_data:
                        level = song_obj.tja.metadata.course_data[course].level

                        scores = song_obj.box.scores.get(course)
                        if scores is not None:
                            is_cleared = scores[4] >= Crown.CLEAR if scores[4] is not None else False
                            is_full_combo = scores[4] == Crown.FC if scores[4] is not None else False
                        else:
                            is_cleared = False
                            is_full_combo = False

                        if course not in self.diff_sort_statistics:
                            self.diff_sort_statistics[course] = {}

                        if level not in self.diff_sort_statistics[course]:
                            self.diff_sort_statistics[course][level] = [1, int(is_full_combo), int(is_cleared)]
                        else:
                            self.diff_sort_statistics[course][level][0] += 1
                            if is_full_combo:
                                self.diff_sort_statistics[course][level][1] += 1
                            elif is_cleared:
                                self.diff_sort_statistics[course][level][2] += 1
                    if song_obj.is_recent:
                        self.new_items.append(SongFile(tja_path, tja_path.name, back_color, fore_color, texture_index))
                    self.song_count += 1
                    global_data.song_progress = self.song_count / global_data.total_songs
                    self.all_song_files[song_key] = song_obj

                if song_key in self.all_song_files:
                    content_items.append(self.all_song_files[song_key])

            self.directory_contents[dir_key] = content_items

        else:
            # For directories without box.def, still process their children
            for item_path in dir_path.iterdir():
                if item_path.is_dir():
                    self._generate_objects_recursive(item_path)

            # Create SongFile objects for TJA files in non-boxed directories
            tja_files = self._find_tja_files_in_directory_only(dir_path)
            for tja_path in tja_files:
                song_key = str(tja_path)
                if song_key not in self.all_song_files:
                    try:
                        song_obj = SongFile(tja_path, tja_path.name, None, None, TextureIndex.DEFAULT)
                        self.song_count += 1
                        global_data.song_progress = self.song_count / global_data.total_songs
                        self.all_song_files[song_key] = song_obj
                    except Exception as e:
                        logger.error(f"Error creating SongFile for {tja_path}: {e}")
                        continue

    def is_at_root(self) -> bool:
        """Check if currently at the virtual root"""
        return self.current_dir == Path()

    def load_current_directory(self, selected_item: Optional[Directory] = None):
        """Load pre-generated items for the current directory (unified for root and subdirs)"""
        dir_key = str(self.current_dir)

        # Determine if current directory has child directories with box.def
        has_children = False
        if self.is_at_root() or selected_item and selected_item.box.genre_index == GenreIndex.DAN:
            has_children = True  # Root always has "children" (the root directories)
        else:
            has_children = any(item.is_dir() and (item / "box.def").exists()
                             for item in self.current_dir.iterdir())

        self.genre_bg = None
        self.in_favorites = False

        if has_children:
            self.items = []
            if not self.box_open:
                self.selected_index = 0

        start_box = None
        end_box = None

        # Add back navigation item (only if not at root)
        if not self.is_at_root():
            back_dir = Directory(self.current_dir.parent, "", BackBox.COLOR, BackBox.COLOR, TextureIndex.BLANK, GenreIndex.DEFAULT, back=True)
            if not has_children:
                start_box = back_dir.box
            self.items.insert(self.selected_index, back_dir)

        # Add pre-generated content for this directory
        if dir_key in self.directory_contents:
            content_items = self.directory_contents[dir_key]

            # Handle special collections (same logic as before)
            if isinstance(selected_item, Directory):
                if selected_item.collection == Directory.COLLECTIONS[0]:
                    content_items = self.new_items
                elif selected_item.collection == Directory.COLLECTIONS[1]:
                    if self.recent_folder is None:
                        raise Exception("tried to enter recent folder without recents")
                    self._generate_objects_recursive(self.recent_folder.path)
                    if not isinstance(selected_item.box, BackBox):
                        selected_item.box.tja_count = self._count_tja_files(self.recent_folder.path)
                    content_items = self.directory_contents[dir_key]
                elif selected_item.collection == Directory.COLLECTIONS[2]:
                    if self.favorite_folder is None:
                        raise Exception("tried to enter favorite folder without favorites")
                    self._generate_objects_recursive(self.favorite_folder.path)
                    tja_files = self._get_tja_files_for_directory(self.favorite_folder.path)
                    self._calculate_directory_crowns(dir_key, tja_files)
                    if not isinstance(selected_item.box, BackBox):
                        selected_item.box.tja_count = self._count_tja_files(self.favorite_folder.path)
                    content_items = self.directory_contents[dir_key]
                    self.in_favorites = True
                elif selected_item.collection == Directory.COLLECTIONS[3]:
                    content_items = []
                    parent_dir = selected_item.path.parent
                    for sibling_path in parent_dir.iterdir():
                        if sibling_path.is_dir() and sibling_path != selected_item.path:
                            sibling_key = str(sibling_path)
                            if sibling_key in self.directory_contents:
                                for item in self.directory_contents[sibling_key]:
                                    if isinstance(item, SongFile) and item:
                                        if self.diff_sort_diff in item.tja.metadata.course_data and item.tja.metadata.course_data[self.diff_sort_diff].level == self.diff_sort_level:
                                            if item not in content_items:
                                                content_items.append(item)
                elif selected_item.collection == Directory.COLLECTIONS[4]:
                    parent_dir = selected_item.path.parent
                    temp_items = []
                    for sibling_path in parent_dir.iterdir():
                        if sibling_path.is_dir() and sibling_path != selected_item.path:
                            sibling_key = str(sibling_path)
                            if sibling_key in self.directory_contents:
                                for item in self.directory_contents[sibling_key]:
                                    if not isinstance(item, Directory) and isinstance(item, SongFile):
                                        temp_items.append(item)
                    content_items = random.sample(temp_items, min(10, len(temp_items)))

            if content_items == []:
                self.go_back()
                return
            i = 1
            for item in content_items:
                if isinstance(item, SongFile) and not has_children:
                    if i % 10 == 0 and i != 0:
                        back_dir = Directory(self.current_dir.parent, "", BackBox.COLOR, BackBox.COLOR, TextureIndex.BLANK, GenreIndex.DEFAULT, back=True)
                        self.items.insert(self.selected_index+i, back_dir)
                        i += 1
                if not has_children:
                    if selected_item is not None:
                        item.box.back_color = selected_item.box.back_color
                        item.box.genre_index = selected_item.box.genre_index
                        item.box.texture_index = selected_item.box.texture_index
                    self.items.insert(self.selected_index+i, item)
                else:
                    self.items.append(item)
                i += 1

            if not has_children:
                self.box_open = True
                end_box = content_items[-1].box
                if selected_item in self.items:
                    self.items.remove(selected_item)

        # Calculate crowns for directories
        for item in self.items:
            if isinstance(item, Directory):
                item_key = str(item.path)
                if isinstance(item.box, FolderBox):
                    item.box.crown = self._get_directory_crowns_cached(item_key)

        self.calculate_box_positions()

        if selected_item and isinstance(selected_item.box, FolderBox):
            if (not has_children and start_box is not None
                and end_box is not None and selected_item is not None
                and selected_item.box.hori_name is not None):
                hori_name = selected_item.box.hori_name
                diff_sort = None
                if selected_item.collection == Directory.COLLECTIONS[3]:
                    diff_sort = self.diff_sort_level
                    diffs = ['かんたん', 'ふつう', 'むずかしい', 'おに']
                    hori_name = OutlinedText(diffs[min(Difficulty.ONI, self.diff_sort_diff)], tex.skin_config["song_hori_name"].font_size, ray.WHITE, outline_thickness=5)
                self.genre_bg = GenreBG(start_box, end_box, hori_name, diff_sort)

    def select_current_item(self):
        """Select the currently highlighted item"""
        if not self.items or self.selected_index >= len(self.items):
            return

        selected_item = self.items[self.selected_index]

        if isinstance(selected_item, Directory):
            if self.box_open:
                self.go_back()

            if selected_item.back:
                # Handle back navigation
                if self.current_dir.parent == Path():
                    # Going back to root
                    self.current_dir = Path()
                else:
                    self.current_dir = self.current_dir.parent
            else:
                # Save current state to history
                self.history.append((self.current_dir, self.selected_index))
                self.current_dir = selected_item.path
                logger.info(f"Entered Directory {selected_item.path}")

            self.load_current_directory(selected_item=selected_item)

        return selected_item

    def go_back(self):
        """Navigate back to the previous directory"""
        if self.history:
            previous_dir, previous_index = self.history.pop()
            self.current_dir = previous_dir
            self.selected_index = previous_index
            self.load_current_directory()
            self.box_open = False

    def _count_tja_files(self, folder_path: Path):
        """Count TJA files in directory"""
        tja_count = 0

        # Find all song_list.txt files recursively
        song_list_files = list(folder_path.rglob("song_list.txt"))

        if song_list_files:
            # Process all song_list.txt files found
            for song_list_path in song_list_files:
                with open(song_list_path, 'r', encoding='utf-8-sig') as song_list_file:
                    tja_count += len([line for line in song_list_file.readlines() if line.strip()])
        # Fallback: Use recursive counting of .tja or dan.json files
        tja_count += sum(1 for _ in list(folder_path.rglob("*.tja")) + list(folder_path.rglob("dan.json")))

        return tja_count

    def _get_directory_crowns_cached(self, dir_key: str) -> dict:
        """Get crowns for a directory, calculating only if needed"""
        if dir_key in self.crown_cache_dirty or dir_key not in self.directory_crowns:
            # Calculate crowns on-demand
            tja_files = self.directory_contents[dir_key]
            self._calculate_directory_crowns(dir_key, tja_files)
            self.crown_cache_dirty.discard(dir_key)

        return self.directory_crowns.get(dir_key, dict())

    def _calculate_directory_crowns(self, dir_key: str, tja_files: list):
        """Pre-calculate crowns for a directory"""
        all_scores = dict()
        child_has_any_crown = []  # Track if each child has been played at all

        for item in tja_files:
            has_crown = False
            if isinstance(item, SongFile):
                has_crown = any((item.box.scores.get(d) or (None,)*6)[5] is not None
                              for d in [Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD, Difficulty.ONI])
                for diff in item.box.scores:
                    if diff not in all_scores:
                        all_scores[diff] = []
                    all_scores[diff].append(item.box.scores[diff])
            elif isinstance(item, Directory):
                child_key = str(item.path)
                child_crowns = self._get_directory_crowns_cached(child_key)
                has_crown = bool(child_crowns)  # Directory is "played" if it has any crowns

                if not child_crowns:
                    # Unplayed directory - add None for all difficulties
                    for diff in [Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD, Difficulty.ONI]:
                        if diff not in all_scores:
                            all_scores[diff] = []
                        all_scores[diff].append((None, None, None, None, None, None))
                else:
                    # Played directory - add its crowns
                    for diff in [Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD, Difficulty.ONI]:
                        if diff not in all_scores:
                            all_scores[diff] = []
                        if diff in child_crowns:
                            all_scores[diff].append((None, None, None, None, None, child_crowns[diff]))
                        else:
                            # This directory doesn't have this difficulty, but it's been played
                            # Don't add anything - this child doesn't count for this difficulty
                            pass

            child_has_any_crown.append(has_crown)

        # If ANY child is completely unplayed, no crowns at all
        if not all(child_has_any_crown):
            self.directory_crowns[dir_key] = {}
            return

        crowns = {}
        for diff in all_scores:
            if any(score is None or score[5] is None for score in all_scores[diff]):
                continue
            if all(score[5] == Crown.DFC for score in all_scores[diff]):
                crowns[diff] = Crown.DFC
            elif all(score[5] == Crown.FC for score in all_scores[diff]):
                crowns[diff] = Crown.FC
            elif all(score[5] >= Crown.CLEAR for score in all_scores[diff]):
                crowns[diff] = Crown.CLEAR

        self.directory_crowns[dir_key] = crowns

    def _get_tja_files_for_directory(self, directory: Path):
        """Get TJA files for a specific directory"""
        if (directory / 'song_list.txt').exists():
            return self._read_song_list(directory)
        else:
            return self._find_tja_files_in_directory_only(directory)

    def _find_tja_files_in_directory_only(self, directory: Path):
        """Find TJA files only in the specified directory, not recursively in subdirectories with box.def"""
        tja_files: list[Path] = []

        for path in directory.iterdir():
            if (path.is_file() and path.suffix.lower() == ".tja") or path.name == "dan.json":
                tja_files.append(path)
            elif path.is_dir():
                # Only recurse into subdirectories that don't have box.def
                sub_dir_has_box_def = (path / "box.def").exists()
                if not sub_dir_has_box_def:
                    tja_files.extend(self._find_tja_files_in_directory_only(path))

        return tja_files

    def _find_tja_files_recursive(self, directory: Path, box_def_dirs_only=True):
        tja_files: list[Path] = []

        has_box_def = (directory / "box.def").exists()
        if box_def_dirs_only and has_box_def and directory != self.current_dir:
            return []

        for path in directory.iterdir():
            if path.is_file() and path.suffix.lower() == ".tja":
                tja_files.append(path)
            elif path.is_dir():
                sub_dir_has_box_def = (path / "box.def").exists()
                if not sub_dir_has_box_def:
                    tja_files.extend(self._find_tja_files_recursive(path, box_def_dirs_only))

        return tja_files

    def _read_song_list(self, path: Path):
        """Read and process song_list.txt file"""
        tja_files: list[Path] = []
        updated_lines = []
        file_updated = False
        with open(path / 'song_list.txt', 'r', encoding='utf-8-sig') as song_list:
            for line in song_list:
                line = line.strip()
                if not line:
                    continue

                parts = line.split('|')
                if len(parts) < 3:
                    continue

                hash_val, title, subtitle = parts[0], parts[1], parts[2]
                original_hash = hash_val

                if hash_val in global_data.song_hashes:
                    for entry in global_data.song_hashes[hash_val]:
                        file_path = Path(entry["file_path"])
                        if file_path.exists() and file_path not in tja_files:
                            tja_files.append(file_path)
                else:
                    # Try to find by title and subtitle
                    for key, value in global_data.song_hashes.items():
                        for i in range(len(value)):
                            song = value[i]
                            if (song["title"]["en"].strip() == title and
                                song["subtitle"]["en"].strip() == subtitle.removeprefix('--') and
                                Path(song["file_path"]).exists()):
                                hash_val = key
                                tja_files.append(Path(global_data.song_hashes[hash_val][i]["file_path"]))
                                break

                if hash_val != original_hash:
                    file_updated = True
                updated_lines.append(f"{hash_val}|{title}|{subtitle.removeprefix('--')}")

        # Write back updated song list if needed
        if file_updated:
            with open(path / 'song_list.txt', 'w', encoding='utf-8-sig') as song_list:
                for line in updated_lines:
                    logger.info(f"updated: {line}")
                    song_list.write(line + '\n')

        return tja_files

    def calculate_box_positions(self):
        """Dynamically calculate box positions based on current selection with wrap-around support"""
        if not self.items:
            return

        for i, item in enumerate(self.items):
            offset = i - self.selected_index

            if offset > len(self.items) // 2:
                offset -= len(self.items)
            elif offset < -len(self.items) // 2:
                offset += len(self.items)

            # Adjust spacing based on dan select mode
            base_spacing = 100 * tex.screen_scale
            center_offset = 150 * tex.screen_scale
            side_offset_l = 0 * tex.screen_scale
            side_offset_r = 300 * tex.screen_scale

            if self.in_dan_select:
                base_spacing = 150 * tex.screen_scale
                side_offset_l = 200 * tex.screen_scale
                side_offset_r = 500 * tex.screen_scale

            position = (BOX_CENTER - center_offset) + (base_spacing * offset)
            if position == BOX_CENTER - center_offset:
                position += center_offset
            elif position > BOX_CENTER - center_offset:
                position += side_offset_r
            else:
                position -= side_offset_l

            if item.box.position == float('inf'):
                item.box.position = position
                item.box.target_position = position
            else:
                item.box.target_position = position

    def draw_boxes(self, move_away_attribute: float, is_ura: bool, diff_fade_out_attribute: float):
        for item in self.items:
            box = item.box
            fade = 1.0
            if self.genre_bg and self.genre_bg.start_position <= box.position <= self.genre_bg.end_position_final:
                fade = self.genre_bg.box_fade_in.attribute
            if (-156 * tex.screen_scale) <= box.position <= (tex.screen_width + 144) * tex.screen_scale:
                if box.position <= (500 * tex.screen_scale):
                    box.draw(box.position - int(move_away_attribute), tex.skin_config["boxes"].y, is_ura, inner_fade_override=diff_fade_out_attribute, outer_fade_override=fade)
                else:
                    box.draw(box.position + int(move_away_attribute), tex.skin_config["boxes"].y, is_ura, inner_fade_override=diff_fade_out_attribute, outer_fade_override=fade)

    def mark_crowns_dirty_for_song(self, song_file: SongFile):
        """Mark directories as needing crown recalculation when a song's score changes"""
        song_path = song_file.path

        # Find all directories that contain this song and mark them as dirty
        for dir_key, content_items in self.directory_contents.items():
            for item in content_items:
                if isinstance(item, SongFile) and item.path == song_path:
                    self.crown_cache_dirty.add(dir_key)
                    break

    def navigate_left(self):
        """Move selection left with wrap-around"""
        if self.items:
            if self.items[0].box.move is not None and not self.items[0].box.move.is_finished:
                return
            self.selected_index = (self.selected_index - 1) % len(self.items)
            self.calculate_box_positions()
            logger.info(f"Moved Left to {self.items[self.selected_index].path}")

    def navigate_right(self):
        """Move selection right with wrap-around"""
        if self.items:
            if self.items[0].box.move is not None and not self.items[0].box.move.is_finished:
                return
            self.selected_index = (self.selected_index + 1) % len(self.items)
            self.calculate_box_positions()
            logger.info(f"Moved Right to {self.items[self.selected_index].path}")

    def skip_left(self):
        if self.items:
            self.selected_index = (self.selected_index - 10) % len(self.items)
            self.calculate_box_positions()
            logger.info(f"Skipped Left to {self.items[self.selected_index].path}")

    def skip_right(self):
        if self.items:
            self.selected_index = (self.selected_index + 10) % len(self.items)
            self.calculate_box_positions()
            logger.info(f"Skipped Right to {self.items[self.selected_index].path}")

    def get_current_item(self):
        """Get the currently selected item"""
        if self.items and 0 <= self.selected_index < len(self.items):
            return self.items[self.selected_index]
        raise Exception("No current item available")

    def reset_items(self):
        """Reset the items in the song select scene"""
        song = self.get_current_item()
        if isinstance(song.box, SongBox):
            if song.box.yellow_box is not None:
                song.box.yellow_box.create_anim()

    def add_recent(self):
        """Add the current song to the recent list"""
        song = self.get_current_item()
        if isinstance(song, Directory):
            return
        if self.recent_folder is None:
            return

        recents_path = self.recent_folder.path / 'song_list.txt'
        new_entry = f'{song.hash}|{song.tja.metadata.title["en"]}|{song.tja.metadata.subtitle["en"]}\n'
        existing_entries = []
        if recents_path.exists():
            with open(recents_path, 'r', encoding='utf-8-sig') as song_list:
                existing_entries = song_list.readlines()
        existing_entries = [entry for entry in existing_entries if not entry.startswith(f'{song.hash}|')]
        all_entries = [new_entry] + existing_entries
        recent_entries = all_entries[:25]
        with open(recents_path, 'w', encoding='utf-8-sig') as song_list:
            song_list.writelines(recent_entries)

        logger.info(f"Added Recent: {song.hash} {song.tja.metadata.title['en']} {song.tja.metadata.subtitle['en']}")

    def add_favorite(self) -> bool:
        """Add the current song to the favorites list"""
        song = self.get_current_item()
        if isinstance(song, Directory):
            return False
        if self.favorite_folder is None:
            return False
        favorites_path = self.favorite_folder.path / 'song_list.txt'
        lines = []
        if not Path(favorites_path).exists():
            Path(favorites_path).touch()
        with open(favorites_path, 'r', encoding='utf-8-sig') as song_list:
            for line in song_list:
                line = line.strip()
                if not line:  # Skip empty lines
                    continue
                hash, title, subtitle = line.split('|')
                if song.hash == hash or (song.tja.metadata.title['en'] == title and song.tja.metadata.subtitle['en'] == subtitle):
                    if not self.in_favorites:
                        return False
                else:
                    lines.append(line)
        if self.in_favorites:
            with open(favorites_path, 'w', encoding='utf-8-sig') as song_list:
                for line in lines:
                    song_list.write(line + '\n')
            logger.info(f"Removed Favorite: {song.hash} {song.tja.metadata.title['en']} {song.tja.metadata.subtitle['en']}")
        else:
            with open(favorites_path, 'a', encoding='utf-8-sig') as song_list:
                song_list.write(f'{song.hash}|{song.tja.metadata.title["en"]}|{song.tja.metadata.subtitle["en"]}\n')
            logger.info(f"Added Favorite: {song.hash} {song.tja.metadata.title['en']} {song.tja.metadata.subtitle['en']}")
        return True

navigator = FileNavigator()
