"""
scoreboard.py

Generates scoreboard images for autologging match results.
Renders a styled PNG with blurred map backgrounds, player stats, vehicle icons,
team compositions, win/loss records, and squadron point diffs using PIL.
"""

# Standard Library Imports
import asyncio
import cProfile
import io
import logging
import os
import pstats
import re
from datetime import datetime, timezone
from functools import lru_cache
from pathlib import Path

# Third-Party Library Imports
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from PIL.Image import Resampling

# Local Module Imports
from . import SHARED_DIR

# Toggle for data_parser dependency
# Set to False to avoid importing data_parser and show all vehicles as "?" in team composition
USE_DATA_PARSER = True

if USE_DATA_PARSER:
    from data_parser import count_unit_types, apply_vehicle_name_filters
else:
    def count_unit_types(internal_name_list):
        """Fallback: return all vehicles as unknown type."""
        player_count = len([v for v in internal_name_list if v != "MEOW"])
        return {"?": player_count} if player_count > 0 else {}
    def apply_vehicle_name_filters(name):
        return name

BASE_DIR = Path(__file__).resolve().parent
MAPS_DIR = SHARED_DIR / "MAPS"
ICON_BASE_DIR = SHARED_DIR / "ICONS"
TEXT_FONT_PATH = SHARED_DIR / "FONTS" / "arial_unicode_ms.otf"


def _normalize_squad_key(value: str | None) -> str:
    """Casefold and trim identifiers so scoreboard + prefs align."""
    if not value:
        return ""
    return re.sub(r"\s+", " ", value).strip().casefold()

FONTS = {}

def load_fonts(base_width):
    """
    Load and cache all fonts for scoreboard rendering.
    Re-uses cache if already loaded.
    """
    global FONTS

    if FONTS:
        return FONTS
    
    # main text fonts (measurable)
    FONTS = {
        "title":   ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.04)),
        "team":    ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.03)),
        "body":    ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.0175)),
        "stat":    ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.022)),
        "comp":    ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.018)),
        "winloss": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.023)),
        "info":    ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.016)),
        "small":   ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.014)),
    }

    return FONTS


# ──────────────────────────────────────────────────────────────────────────────────────────────
# Icon Caching Functions
# ──────────────────────────────────────────────────────────────────────────────────────────────

@lru_cache(maxsize=200)
def _load_icon_cached(icon_path_str: str, size_tuple: tuple, resample_mode: int):
    """
    Internal cached function. Loads icon from disk and resizes.
    Uses only hashable types for lru_cache compatibility.
    LRU cache with maxsize=200 automatically evicts least-used icons.
    """
    icon_path = Path(icon_path_str)

    # Load and convert to RGBA
    img = Image.open(icon_path).convert("RGBA")

    # Resize if size specified
    if size_tuple:
        img = img.resize(size_tuple, resample_mode)

    return img


def load_cached_icon(icon_path, size=None, resample_filter=Image.Resampling.LANCZOS):
    """
    Load and cache icon images for scoreboard rendering.
    LRU cache with maxsize=200 automatically evicts least-used icons.

    Args:
        icon_path: Path object or string to icon file
        size: Optional (width, height) tuple for resizing
        resample_filter: Resampling filter for resize (default: LANCZOS)

    Returns:
        PIL Image object in RGBA mode
    """
    # Convert to hashable types for lru_cache
    path_str = str(icon_path)
    size_tuple = tuple(size) if size else None
    resample_int = int(resample_filter)

    return _load_icon_cached(path_str, size_tuple, resample_int)


@lru_cache(maxsize=20)
def _load_map_background_cached(map_path_str: str, blur_radius: int):
    """
    Internal cached function. Loads map background and applies blur.
    LRU cache with maxsize=20 stores up to 20 blurred map backgrounds.
    """
    map_path = Path(map_path_str)

    # Load, convert, and blur
    background = Image.open(map_path).convert("RGBA")
    background = background.filter(ImageFilter.GaussianBlur(radius=blur_radius))

    return background


def load_cached_map_background(map_path, blur_radius=2):
    """
    Load and cache map background images with blur applied.
    LRU cache with maxsize=20 prevents excessive memory usage.

    Args:
        map_path: Path to map background image
        blur_radius: Gaussian blur radius to apply

    Returns:
        PIL Image object in RGBA mode with blur applied
    """
    return _load_map_background_cached(str(map_path), blur_radius)


def get_pts_color(value: int) -> tuple[int,int,int,int]:
    """
    Color gradient based on points:
    1500 = green, 1600 = yellow, 1750 = orange,
    1850 = red-orange, 1900+ = red.
    Below 1500 = green.
    """

    if value is None:
        return (180, 180, 180, 255)  # grey

    # Green for anything below 1500
    if value < 1500:
        value = 1500

    # Phase 1: 1500-1600 green → yellow
    if value < 1600:
        progress = (value - 1500) / 100.0
        r = int(255 * progress)  # 0 → 255
        g = 255
        b = 0

    # Phase 2: 1600-1750 yellow → orange
    elif value < 1750:
        progress = (value - 1600) / 150.0
        r = 255
        g = int(255 - (115 * progress))  # 255 → 140
        b = 0

    # Phase 3: 1750-1850 orange → red-orange
    elif value < 1850:
        progress = (value - 1750) / 100.0
        r = 255
        g = int(140 - (80 * progress))  # 140 → 60
        b = 0

    # Phase 4: 1850+ red-orange → red
    else:
        if value > 1900:
            value = 1900
        progress = (value - 1850) / 50.0
        r = 255
        g = int(60 - (60 * progress))  # 60 → 0
        b = 0

    return (r, g, b, 255)


def get_gradient_color(win_rate):
    """
    Calculate color gradient from red to yellow to lime green based on win percentage.
    0% = Red (255, 0, 0), 50% = Yellow (255, 255, 0), 100% = Lime Green (0, 255, 0)
    Transitions smoothly in 1% intervals
    """
    win_rate = max(0, min(100, win_rate))

    if win_rate <= 50:
        red = 255
        green = int(255 * (win_rate / 50))
        blue = 0
    else:
        red = int(255 * (1 - (win_rate - 50) / 50))
        green = 255
        blue = 0

    return (red, green, blue, 255)


def make_vignette(width, height, base_alpha=140, max_alpha=175, power=4):
    """Build a radial alpha mask for a vignette overlay.

    Args:
        width: Image width in pixels.
        height: Image height in pixels.
        base_alpha: Minimum alpha at the center (0-255).
        max_alpha: Maximum alpha at the edges (0-255).
        power: Exponent controlling vignette falloff curve.

    Returns:
        A PIL Image in "L" mode containing the alpha mask.
    """
    base_alpha = max(0, min(255, base_alpha))
    max_alpha  = max(0, min(255, max_alpha))
    if max_alpha < base_alpha:
        max_alpha = base_alpha

    y, x = np.ogrid[:height, :width]
    cx, cy = width / 2.0, height / 2.0
    dx = (x - cx) / cx
    dy = (y - cy) / cy
    d = np.sqrt(dx*dx + dy*dy)
    d = np.clip(d, 0, 1)

    alpha = base_alpha + (max_alpha - base_alpha) * (d ** power)
    alpha = np.clip(alpha, 0, 255).astype(np.uint8)

    return Image.fromarray(alpha, mode="L")


# ──────────────────────────────────────────────────────────────────────────────────────────────
# 1) Synchronous helper that does all the heavy PIL/math work and saves to disk.
#    (Run this on a worker thread via asyncio.to_thread.)
# ──────────────────────────────────────────────────────────────────────────────────────────────
def _create_scoreboard_sync(match_details,
                            winning_team,
                            team1_details,
                            team2_details,
                            map_file,
                            output_path,
                            bar_color="",
                            diffs=None, WL=None, is_draw=False):

    """CPU-bound routine that renders the full scoreboard image and saves it.

    Loads the map background, builds a vignette gradient, draws all text/icons
    for both teams, resizes/compresses, and writes the final PNG.

    Args:
        match_details: Dict with match metadata (utc_timestamp, session_id).
        winning_team: Squadron short name of the winner.
        team1_details: Dict with "squadron" and "players" list for team 1.
        team2_details: Dict with "squadron" and "players" list for team 2.
        map_file: Map display name (e.g. "Abandoned Factory").
        output_path: Filesystem path to write the output PNG.
        bar_color: Color hint for the header bar ("win", "loss", or "").
        diffs: Squadron point diffs dict, keyed by squadron name.
        WL: Win/loss record dict, keyed by squadron name.
        is_draw: Whether the match ended in a draw.
    """

    # ── A) Figure out paths & background
    map_file_clean = re.sub(r"^\s*\[[^]]+\]\s*", "", map_file)
    map_name = map_file_clean
    map_file_clean = map_file_clean.replace(" ", "_")

    target = f"{map_file_clean}.jpg"

    # look for any file in MAPS whose name matches target, ignoring case
    try:
        candidates = {f.name for f in MAPS_DIR.iterdir() if f.is_file()}
    except FileNotFoundError:
        map_image_path = str(MAPS_DIR / target)
    else:
        match = next((fn for fn in candidates if fn.lower() == target.lower()), None)
        if match:
            map_image_path = str(MAPS_DIR / match)
        else:
            map_image_path = str(MAPS_DIR / target)

    # Load base background (with caching)
    blur_power = 2
    try:
        background = load_cached_map_background(map_image_path, blur_radius=blur_power)
    except Exception as e:
        logging.error(f"[Scoreboard] Failed to open map image {map_image_path}: {e}")
        raise

    bg_width, bg_height = background.size
    margin = 0

    # ── B) Build vignette overlay (NumPy vectorized)
    alpha_band = make_vignette(bg_width, bg_height, base_alpha=140, max_alpha=175, power=4)

    # Build a black overlay with that alpha gradient
    overlay   = Image.new("RGBA", (bg_width, bg_height), (0, 0, 0, 0))
    black_rgb = Image.new("RGBA", (bg_width, bg_height), (0, 0, 0, 255))
    overlay   = Image.composite(black_rgb, overlay, alpha_band)

    draw = ImageDraw.Draw(overlay)

    BODY_FONT_SIZE     = int(bg_width * 0.0175)
    STAT_FONT_SIZE     = int(bg_width * 0.022)

    # ── C) Load fonts
    fonts = load_fonts(bg_width)
    font_title   = fonts["title"]
    font_team    = fonts["team"]
    font_body    = fonts["body"]
    stat_font    = fonts["stat"]
    comp_font    = fonts["comp"]
    winloss_font = fonts["winloss"]
    info_font    = fonts["info"]
    small_font   = fonts["small"]


    resample_filter = Image.Resampling.LANCZOS

    normalized_diffs = {}
    if isinstance(diffs, dict) and diffs:
        for key, value in diffs.items():
            norm_key = _normalize_squad_key(key)
            if norm_key and norm_key not in normalized_diffs:
                normalized_diffs[norm_key] = (key, value)


    # ── D) Draw match_details (timestamp + session ID) in top-right
    padding = 15
    ts_epoch = int(match_details["utc_timestamp"])
    dt_utc = datetime.fromtimestamp(ts_epoch, tz=timezone.utc)
    ts_text  = dt_utc.strftime("%H:%M:%S - %Y-%m-%d UTC")
    sid_text = f"{match_details['session_id']}"

    ts_bbox = draw.textbbox((0,0), ts_text, font=info_font)
    x_ts = bg_width - margin - (ts_bbox[2] - ts_bbox[0]) - padding - 10
    y_ts = margin + padding + 10
    draw.text((x_ts, y_ts), ts_text, font=info_font, fill=(200,200,200,255))

    sid_bbox = draw.textbbox((0,0), sid_text, font=info_font)
    x_id = bg_width - margin - (sid_bbox[2] - sid_bbox[0]) - padding - 10
    y_id = y_ts + (ts_bbox[3] - ts_bbox[1]) + 20
    draw.text((x_id, y_id), sid_text, font=info_font, fill=(200,200,200,255))

    received_raw = match_details.get("received_unix")
    if received_raw is not None:
        try:
            delay = int(received_raw) - ts_epoch
        except (TypeError, ValueError):
            delay = None
        if delay is not None and delay >= 60:
            d_hr, rem = divmod(delay, 3600)
            d_min, d_sec = divmod(rem, 60)
            delay_text = f"TTL: {d_hr:02d}:{d_min:02d}:{d_sec:02d}"
            delay_color = (255, 60, 60, 255) if delay > 300 else (200, 200, 200, 255)
            delay_bbox = draw.textbbox((0, 0), delay_text, font=info_font)
            x_delay = bg_width - margin - (delay_bbox[2] - delay_bbox[0]) - padding - 10
            y_delay = y_id + (sid_bbox[3] - sid_bbox[1]) + 20
            draw.text((x_delay, y_delay), delay_text, font=info_font, fill=delay_color)

    # ── E) Draw top titles (map name + winner)
    title_text = map_name
    win_text   = "DRAW" if is_draw else f"Winner - {winning_team}"
    y = 50

    # Centered map-name
    title_bbox = draw.textbbox((0,0), title_text, font=font_title)
    title_width = title_bbox[2] - title_bbox[0]
    x_center = (bg_width - title_width) // 2
    draw.text((x_center, y), title_text, font=font_title, fill=(255,255,255,255))
    title_height = title_bbox[3] - title_bbox[1]
    y += title_height + 40

    win_loss_data = WL or {}

    # Squadrons for each team
    team1_squadron = team1_details.get("squadron", "Unknown")
    team2_squadron = team2_details.get("squadron", "Unknown")

    # Pull stats directly from the WL dict instead of hitting storage
    stats1 = win_loss_data.get(team1_squadron, {"wins": 0, "losses": 0})
    stats2 = win_loss_data.get(team2_squadron, {"wins": 0, "losses": 0})

    # Unpack for drawing
    team1_wins, team1_losses = stats1["wins"], stats1["losses"]
    team2_wins, team2_losses = stats2["wins"], stats2["losses"]
    
    # Store winloss data as raw values: (win_str, loss_str, percent_str, fill_color)
    team1_total = team1_wins + team1_losses
    if team1_total > 0:
        team1_win_rate = (team1_wins / team1_total) * 100
        team1_winloss_data = (str(team1_wins), str(team1_losses), f"{team1_win_rate:.0f}%", get_gradient_color(team1_win_rate))
    else:
        team1_winloss_data = None

    team2_total = team2_wins + team2_losses
    if team2_total > 0:
        team2_win_rate = (team2_wins / team2_total) * 100
        team2_winloss_data = (str(team2_wins), str(team2_losses), f"{team2_win_rate:.0f}%", get_gradient_color(team2_win_rate))
    else:
        team2_winloss_data = None

    # Centered winner text (not drawn — winner/draw is conveyed by team name colors)
    win_bbox = draw.textbbox((0,0), win_text, font=font_title)
    win_height = win_bbox[3] - win_bbox[1]
    y_start = y + win_height + 60  # Back to normal spacing since winloss moved to icon level

    # ── F) Compute layout for two team columns
    x_start      = margin + 45
    gap_between  = 0
    col_width    = (bg_width - (x_start * 2) - gap_between) // 2

    def draw_team(idx, team_data, start_x, start_y, section_width, buffer):
        """Draw one team's block onto the scoreboard canvas.

        Renders squadron header, player rows (nick, vehicle icon, stats),
        team composition, and W/L record. Layout is mirrored for team 2.

        Args:
            idx: Team index (1 or 2). Team 2 is drawn right-aligned.
            team_data: Dict with "squadron", "players", and optional metadata.
            start_x: Left x-coordinate of the team column.
            start_y: Top y-coordinate to begin drawing.
            section_width: Available pixel width for this team's column.
            buffer: Shared list collecting the running y-position for layout.
        """
        flipped = (idx == 2)

        Username_fill        = (250, 227, 200, 255)
        Living_vehicle_fill  = (255, 255, 255, 255)
        Dead_vehicle_fill    = (200, 200, 200, 255)
        Positive_Points_fill = (60, 255, 60, 255)
        Negative_Points_fill = (255, 60, 60, 255)
        Unknown_Points_fill  = (200, 200, 200, 255)


        # --- 1) Squadron header & points ---
        # squadron_raw: used for win/loss comparison against winning_team (raw replay value)
        # squadron_short: resolved clean name used for display only
        squadron_raw   = team_data.get("squadron", "Unknown")
        squadron_short = team_data.get("squadron_short") or squadron_raw
        squadron_long  = team_data.get("squadron_long", "Unknown")
        matched_diff_source = None
        squad_diffs = None
        if normalized_diffs:
            for candidate in (squadron_long, squadron_short):
                norm_candidate = _normalize_squad_key(candidate)
                match = normalized_diffs.get(norm_candidate)
                if match:
                    matched_diff_source, squad_diffs = match
                    break

        diff_keys = list(diffs.keys()) if isinstance(diffs, dict) else None


        points_diff     = {}
        current_points  = {}
        sq_string       = ""
        sq_points_fill  = (200, 200, 200, 255)  # neutral grey

        if squad_diffs:
            points_diff    = squad_diffs.get("points_diff", {})
            current_points = squad_diffs.get("current_points", {})
            diff_total     = squad_diffs.get("diff_total", 0)

            sq_points = int(diff_total)
            if sq_points > 0:
                sq_string, sq_points_fill = f"+{sq_points}", Positive_Points_fill
            elif sq_points < 0:
                sq_string, sq_points_fill = str(sq_points), Negative_Points_fill
            else:
                sq_string, sq_points_fill = "0", Unknown_Points_fill

        
        squad_bbox = draw.textbbox((0,0), squadron_short, font=font_team)
        squad_width  = squad_bbox[2] - squad_bbox[0]
        squad_height = squad_bbox[3] - squad_bbox[1]
        header_y = start_y - 10
        text    = squadron_short

        # Yellow color for draws (255, 255, 0, 255)
        Draw_fill = (255, 255, 0, 255)

        if is_draw:
            fill = Draw_fill
        else:
            fill = Positive_Points_fill if squadron_short == winning_team else Negative_Points_fill

        gap = 30
        if not flipped:
            name_x = start_x
            pts_x  = start_x

            if diffs:
                pts_x  = name_x + squad_width + gap
        else:
            name_x = start_x + section_width - squad_width
            pts_x  = start_x

            if diffs:
                sq_bbox = draw.textbbox((0,0), sq_string, font=font_team)
                sq_width = sq_bbox[2] - sq_bbox[0]
                pts_x = name_x - gap - sq_width

        draw.text((name_x, header_y), text, font=font_team, fill=fill)

        if diffs:
            draw.text((pts_x,  header_y), sq_string, font=font_team, fill=sq_points_fill)

        # --- 2) Comp notation below header ---
        notation_list = count_unit_types([p["vehicle"] for p in team_data.get("players", [])])
        comp_order = [
            ("F", "Fighters"), 
            ("B", "Bombers"),
            ("H", "Helicopters"), 
            ("L", "Light"), 
            ("T", "Tanks"),
            ("AA", "AA"), 
            ("?", "?")
        ]

        comp_y = header_y + squad_height + 40

        if not flipped:
            # Build left→right
            comp_x = start_x + 5
            first = True
            for code, _ in comp_order:
                cnt = notation_list.get(code, 0)
                if cnt > 0:
                    txt = f"{cnt}{code}"
                    if not first:
                        sep = "/ "
                        sep_w = draw.textbbox((0, 0), sep, font=comp_font)[2]
                        draw.text((comp_x, comp_y), sep, font=comp_font, fill=(255, 255, 255, 255))
                        comp_x += sep_w + 5
                    draw.text((comp_x, comp_y), txt, font=comp_font, fill=(255, 255, 255, 255))
                    txt_w = draw.textbbox((0, 0), txt, font=comp_font)[2]
                    comp_x += txt_w + 15
                    first = False
        else:
            # Build a single string, then right‐align
            codes_drawn = []
            for code, _ in comp_order:
                cnt = notation_list.get(code, 0)
                if cnt > 0:
                    codes_drawn.append(f"{cnt}{code}")
            if codes_drawn:
                full_comp_str = " / ".join(codes_drawn)
                full_w = draw.textbbox((0, 0), full_comp_str, font=comp_font)[2]
                comp_x = start_x + section_width - 5 - full_w
                draw.text((comp_x, comp_y), full_comp_str, font=comp_font, fill=(255, 255, 255, 255))
            # else: nothing to draw

        # --- 3) Column headers with icons ---
        if not flipped:
            columns = ["", "Air", "Ground", "Assists", "Deaths", "Caps"]
        else:
            columns = ["", "Caps", "Deaths", "Assists", "Ground", "Air"]

        num_stat_cols  = len(columns) - 1
        stat_area_width= int(section_width * 0.32)
        stat_start     = start_x + section_width - stat_area_width + buffer

        col_positions = [start_x] + [
            stat_start + int(i * stat_area_width / num_stat_cols)
            for i in range(num_stat_cols)
        ]
        if flipped:
            col_positions = [
                start_x + (section_width - (x - start_x)) for x in col_positions
            ]


        ICON_SIZE = int(STAT_FONT_SIZE * 1.1)
        base_icon_map = {
            "Air":     ICON_BASE_DIR / "fighter_icon.png",
            "Ground":  ICON_BASE_DIR / "tank_icon.png",
            "Assists": ICON_BASE_DIR / "assists_icon.png",
            "Deaths":  ICON_BASE_DIR / "deaths_icon.png",
            "Caps":    ICON_BASE_DIR / "cap_icon.png"
        }

        if not flipped:
            cols_to_draw = columns
            pos_to_draw  = col_positions
        else:
            cols_to_draw = list(reversed(columns))
            pos_to_draw  = list(reversed(col_positions))

        for i, name in enumerate(cols_to_draw):
            icon_file = base_icon_map.get(name)
            if not icon_file:
                continue
            header_x   = pos_to_draw[i]
            try:
                icon_img = load_cached_icon(icon_file, (ICON_SIZE, ICON_SIZE), resample_filter)
                header_icon_y = header_y + 90
                overlay.paste(icon_img, (header_x - 15, header_icon_y), icon_img)
            except Exception:
                pass

        row_height = ICON_SIZE + 30
        y_offset  = header_y + row_height + 60

        # --- 4) Player rows
        players_sorted = sorted(
            team_data.get("players", []),
            key=lambda p: int(p.get("score", 0)), reverse=True
        )
        
        for player in players_sorted:
            uid = str(player.get("uid"))
            pts_str  = ""
            pts_fill = (200, 200, 200, 255)  # neutral
            pts      = None
            c_pts    = None

            if squad_diffs:  # only if this team was actually tracked
                pts   = points_diff.get(uid)
                c_pts = current_points.get(uid)

                if pts is None:
                    pts_str  = "???"   # tracked, but this player missing in snapshot
                    pts_fill = Unknown_Points_fill
                elif pts > 0:
                    pts_str  = f"+{pts}"
                    pts_fill = Positive_Points_fill
                elif pts < 0:
                    pts_str  = str(pts)
                    pts_fill = Negative_Points_fill
                else:
                    pts_str  = "0"
                    pts_fill = Unknown_Points_fill
            else:
                # not tracked at all by this guild → leave blank
                pts_str = ""




            # --- Prepare icon image ---
            if player.get("vehicle", "") == "DISCONNECTED":

                ICON_PATH = ICON_BASE_DIR / "disconnected.png"
            else:
                ICON_PATH = ICON_BASE_DIR / "VEHICLES" / f"{player.get('vehicle','').lower()}.png"

            icon_display_size = int(BODY_FONT_SIZE * 3.0)
            size_tuple = (icon_display_size, icon_display_size)
            try:
                vicon = load_cached_icon(ICON_PATH, size_tuple, resample_filter)
            except Exception:
                # If vehicle icon fails to load (invalid vehicle), use not_found icon as fallback
                try:
                    vicon = load_cached_icon(
                        ICON_BASE_DIR / "not_found.png",
                        size_tuple,
                        resample_filter
                    )
                except Exception:
                    vicon = None

            # --- Name & (c_pts) formatting (no inline font mixing) ---

            # Prefer fake_nick if present, otherwise fall back to nick
            name_raw = (player.get("fake_nick") or player.get("nick") or "").strip()

            # Strip platform suffixes
            name_raw = name_raw.replace("@live", "").replace("@psn", "")

            vehicle_new = player.get("vehicle_new", "DISCONNECTED")

            # Handle None, empty string, or missing value
            if not vehicle_new:
                vehicle_new = "DISCONNECTED"
            else:
                vehicle_new = apply_vehicle_name_filters(vehicle_new)


            show_pts = bool(diffs)  # only show when diffs data is present

            # compute name/vehicle metrics using only the base name (no c_pts)
            name_bbox    = draw.textbbox((0,0), name_raw, font=font_body)
            name_w       = name_bbox[2] - name_bbox[0]
            name_h       = name_bbox[3] - name_bbox[1]

            vehicle_bbox = draw.textbbox((0,0), vehicle_new, font=font_body)
            vehicle_w    = vehicle_bbox[2] - vehicle_bbox[0]
            vehicle_h    = vehicle_bbox[3] - vehicle_bbox[1]

            identity_w = max(name_w, vehicle_w)
            identity_h = name_h + 5 + vehicle_h

            row_height = max(identity_h, int(BODY_FONT_SIZE * 2.70))
            text_y     = y_offset + (row_height - identity_h) // 2
            icon_y     = y_offset + (row_height - int(BODY_FONT_SIZE * 2.35)) // 2
            player_name_y_offset = 12
            vehicle_name_y_offset = 4

            # === Draw icon + name + vehicle ===
            if not flipped:
                icon_x = start_x
                if vicon:
                    overlay.paste(vicon, (icon_x, icon_y + 3), vicon)

                text_x = icon_x + icon_display_size + 15

                # draw name
                draw.text((text_x, text_y-player_name_y_offset), name_raw, font=font_body, fill=Username_fill)

                if show_pts and c_pts is not None:
                    pts_text = f"({c_pts})"
                    pts_bbox = draw.textbbox((0,0), pts_text, font=small_font)
                    pts_w    = pts_bbox[2] - pts_bbox[0]
                    pts_h    = pts_bbox[3] - pts_bbox[1]
                    pts_x    = text_x + name_w + 8
                    pts_y    = (text_y + (name_h - pts_h) // 2) - player_name_y_offset
                    draw.text((pts_x, pts_y), pts_text, font=small_font, fill=get_pts_color(c_pts))

                # vehicle (left side)
                vehicle_y = (text_y + name_h + 10) - vehicle_name_y_offset
                player_dead = (int(player.get("deaths", 0)) > 0)
                draw.text(
                    (text_x, vehicle_y),
                    vehicle_new,
                    font=font_body,
                    fill=Dead_vehicle_fill if player_dead or vehicle_new == "DISCONNECTED"
                        else Living_vehicle_fill
                )

            else:
                # flipped: icon on the right, name right-aligned against it
                icon_x = start_x + section_width - icon_display_size - 5
                if vicon:
                    overlay.paste(vicon, (icon_x, icon_y + 3), vicon)

                text_x = icon_x - name_w - 15

                # draw name
                draw.text((text_x, text_y-player_name_y_offset), name_raw, font=font_body, fill=Username_fill)

                # draw (c_pts) in smaller font to the LEFT of the name
                if show_pts and c_pts is not None:
                    pts_text = f"({c_pts})"
                    pts_bbox = draw.textbbox((0,0), pts_text, font=small_font)
                    pts_w    = pts_bbox[2] - pts_bbox[0]
                    pts_h    = pts_bbox[3] - pts_bbox[1]
                    pts_x = text_x - 8 - pts_w
                    pts_y = (text_y + (name_h - pts_h) // 2) - player_name_y_offset
                    draw.text((pts_x, pts_y), pts_text, font=small_font, fill=get_pts_color(c_pts))

                # vehicle (right side)
                vehicle_y = (text_y + name_h + 10) - vehicle_name_y_offset
                vehicle_x = icon_x - vehicle_w - 15
                player_dead = (int(player.get("deaths", 0)) > 0)
                draw.text(
                    (vehicle_x, vehicle_y),
                    vehicle_new,
                    font=font_body,
                    fill=Dead_vehicle_fill if player_dead or vehicle_new == "DISCONNECTED"
                        else Living_vehicle_fill
                )


            if diffs:
                # 1) measure your text
                stat_bbox = draw.textbbox((0, 0), pts_str, font=stat_font)
                stat_w    = stat_bbox[2] - stat_bbox[0]
                stat_h    = stat_bbox[3] - stat_bbox[1]

                pts_string_offset = 0 # positive is down

                # 2) vertical centering
                pts_y = (y_offset + (row_height - stat_h) // 2) + pts_string_offset

                # 3) compute your right‐edge anchor
                if not flipped:
                    anchor_x = col_positions[1] - 35
                    pts_x_draw = anchor_x

                    # 4b) draw
                    draw.text((pts_x_draw, pts_y), pts_str, font=stat_font, fill=pts_fill, anchor="ra")

                else:
                    # flipped side stays as before
                    pts_x_draw = col_positions[1] + 65

                    # 4b) draw
                    draw.text((pts_x_draw, pts_y), pts_str, font=stat_font, fill=pts_fill)

            # Draw the five stat columns
            stats = [
                player.get("air_kills", 0),
                player.get("ground_kills", 0),
                player.get("assists", 0),
                player.get("deaths", 0),
                player.get("captures", 0)
            ]

            base_labels = ["Air", "Ground", "Assists", "Deaths", "Caps"]
            if not flipped:
                labels    = base_labels
                positions = col_positions[1:]
            else:
                labels    = base_labels
                positions = list(reversed(col_positions[1:]))

            for val, x_pos, label in zip(stats, positions, labels):
                try:
                    num = int(val)
                except Exception:
                    num = 0

                if label in ("Air","Ground") and num > 0:
                    fill_color = (60, 255, 60, 255)
                elif label == "Deaths" and num > 0:
                    fill_color = (255, 20, 20, 255)
                elif label == "Caps" and num > 0:
                    fill_color = (255, 255, 0, 255)
                elif label == "Assists" and num > 0:
                    fill_color = (230, 150, 90, 255)
                else:
                    fill_color = (255, 255, 255, 255)

                num_str = str(num)
                num_bbox= draw.textbbox((0,0), num_str, font=stat_font)
                num_h   = num_bbox[3] - num_bbox[1]
                stat_y  = y_offset + (row_height - num_h) // 2

                draw.text((x_pos, stat_y), num_str, font=stat_font, fill=fill_color)

            y_offset += row_height + 15

    # ── G) Draw separator line and both teams side-by-side
    dx = 0
    dy_top    = -50
    dy_bottom = 50

    if bar_color == "win":
        bar_color_fill = (60, 255, 60, 255)
    elif bar_color == "loss":
        bar_color_fill = (255, 60, 60, 255)
    elif bar_color == "draw":
        bar_color_fill = (255, 255, 0, 255)
    else:
        bar_color_fill = (255, 255, 255, 255)

    sep_x  = x_start + col_width + gap_between // 2 + dx
    sep_y1 = y_start + dy_top
    sep_y2 = bg_height - margin - dy_bottom

    draw.line([(sep_x, sep_y1), (sep_x, sep_y2)],
              fill=bar_color_fill,
              width=5)

    draw_team(
        idx=1,
        team_data=team1_details,
        start_x=x_start + 10,
        start_y=y_start - 130,
        section_width=col_width,
        buffer=-10
    )
    draw_team(
        idx=2,
        team_data=team2_details,
        start_x=x_start + col_width + gap_between,
        start_y=y_start - 130,
        section_width=col_width,
        buffer=33
    )

    # Draw winloss data in center area between title and icons
    icon_level_y = y_start - 130 - 5  # Slightly down from previous position
    center_x = bg_width // 2
    
    def _draw_winloss(draw, x_start, y, win_num, loss_num, percent_part, pct_fill):
        """Draw colored W-L-% text starting at x_start. Returns nothing."""
        cx = x_start

        # Win number + W (green)
        draw.text((cx, y), win_num, font=winloss_font, fill=(0, 255, 0, 255))
        cx += draw.textbbox((0,0), win_num, font=winloss_font)[2]
        draw.text((cx, y), "W", font=winloss_font, fill=(0, 255, 0, 255))
        cx += draw.textbbox((0,0), "W", font=winloss_font)[2]

        # Dash (grey)
        draw.text((cx, y), " - ", font=winloss_font, fill=(200, 200, 200, 255))
        cx += draw.textbbox((0,0), " - ", font=winloss_font)[2]

        # Loss number + L (red)
        draw.text((cx, y), loss_num, font=winloss_font, fill=(255, 60, 60, 255))
        cx += draw.textbbox((0,0), loss_num, font=winloss_font)[2]
        draw.text((cx, y), "L", font=winloss_font, fill=(255, 60, 60, 255))
        cx += draw.textbbox((0,0), "L", font=winloss_font)[2]

        # Dash (grey)
        draw.text((cx, y), " - ", font=winloss_font, fill=(200, 200, 200, 255))
        cx += draw.textbbox((0,0), " - ", font=winloss_font)[2]

        # Percentage (color depends on win rate)
        draw.text((cx, y), percent_part, font=winloss_font, fill=pct_fill)

    if team1_winloss_data:
        win_num, loss_num, percent_part, wl_fill = team1_winloss_data
        total_text = f"{win_num}W - {loss_num}L - {percent_part}"
        total_bbox = draw.textbbox((0,0), total_text, font=winloss_font)
        total_width = total_bbox[2] - total_bbox[0]
        _draw_winloss(draw, center_x - total_width - 30, icon_level_y, win_num, loss_num, percent_part, wl_fill)

    if team2_winloss_data:
        win_num, loss_num, percent_part, wl_fill = team2_winloss_data
        _draw_winloss(draw, center_x + 30, icon_level_y, win_num, loss_num, percent_part, wl_fill)

    # ── H) Composite overlay onto background, downsample, and save
    final_img = Image.alpha_composite(background, overlay)

    # Lower is more compression, think of it like what percentage of the W / H to keep
    compression_level: float = 0.42
    w, h = final_img.size
    new_size = (int(w * compression_level), int(h * compression_level))

    resized = final_img.resize(new_size, resample=Resampling.LANCZOS)

    # Convert to RGB to remove unused alpha channel (~25% size reduction)
    resized = resized.convert("RGB")

    try:
        resized.save(output_path, format="PNG", compress_level=1, optimize=False)

    except Exception as e:
        logging.error(f"[Scoreboard] ✗ Failed to save to {output_path}: {e}")
        raise


# ──────────────────────────────────────────────────────────────────────────────────────────────
# 2) Async wrapper that simply offloads the above helper to a thread
# ──────────────────────────────────────────────────────────────────────────────────────────────
async def create_scoreboard(match_details,
                            winning_team,
                            team1_details,
                            team2_details,
                            map_file,
                            output_path,
                            bar_color="",
                            diffs=None,
                            WL=None,
                            is_draw=False):
    """Async entry point that offloads scoreboard rendering to a worker thread.

    Args:
        match_details: Dict with match metadata (utc_timestamp, session_id).
        winning_team: Squadron short name of the winner.
        team1_details: Dict with "squadron" and "players" list for team 1.
        team2_details: Dict with "squadron" and "players" list for team 2.
        map_file: Map display name (e.g. "Abandoned Factory").
        output_path: Filesystem path to write the output PNG.
        bar_color: Color hint for the header bar ("win", "loss", or "").
        diffs: Squadron point diffs dict, keyed by squadron name.
        WL: Win/loss record dict, keyed by squadron name.
        is_draw: Whether the match ended in a draw.

    Raises:
        Exception: Re-raised from _create_scoreboard_sync on render failure.
    """
    # Ensure the parent folder is present
    base_dir = os.path.dirname(output_path)
    os.makedirs(base_dir, exist_ok=True)

    try:
        await asyncio.to_thread(_create_scoreboard_sync,
            match_details,
            winning_team,
            team1_details,
            team2_details,
            map_file,
            output_path,
            bar_color,
            diffs,
            WL,
            is_draw
        )

    except Exception as e:
        logging.error(f"[Scoreboard] create_scoreboard_sync failed: {e}")
        raise



# Example usage:
async def test():
    """Generate a test scoreboard image with hardcoded sample match data."""
    team1 = {
        "squadron":
        "AVR",
        "players": [{
            "uid": 140943953,
            "nick": "\u0410VR",
            "index": 5,
            "vehicle": "ussr_pantsyr_s1",
            "vehicle_new": "Meow",
            "air_kills": 1,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 0,
            "captures": 0,
            "score": 320
        }, {
            "uid": 145639262,
            "nick": "bullpuppy\u30c5",
            "index": 6,
            "vehicle": "DISCONNECTED",
            "vehicle_new": "DISCONNECTED",
            "air_kills": 0,
            "ground_kills": 1,
            "assists": 1,
            "deaths": 1,
            "captures": 0,
            "score": 184
        }, {
            "uid": 154923412,
            "nick": "\u041e\u0448\u0438\u0431\u043a\u0430\u30c5",
            "index": 7,
            "vehicle": "germ_leopard_2a7v",
            "vehicle_new": "Leopard 2A7V",
            "air_kills": 0,
            "ground_kills": 2,
            "assists": 2,
            "deaths": 1,
            "captures": 0,
            "score": 600
        }, {
            "uid": 17424877,
            "nick": "\u0396\u039b\u039f",
            "index": 8,
            "vehicle": "ef_2000_block_10",
            "vehicle_new": "EF-2000",
            "air_kills": 0,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 0,
            "captures": 0,
            "score": 0
        }, {
            "uid": 25721576,
            "nick": "1N1ck",
            "index": 9,
            "vehicle": "rafale_c_f3",
            "vehicle_new": "Rafale C F3",
            "air_kills": 1,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 1,
            "captures": 0,
            "score": 355
        }, {
            "uid": 63482105,
            "nick": "link_54",
            "index": 11,
            "vehicle": "rafale_c_f3",
            "vehicle_new": "Rafale C F3",
            "air_kills": 1,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 0,
            "captures": 0,
            "score": 284
        }, {
            "uid": 66489055,
            "nick": "BlackSkr1pt",
            "index": 12,
            "vehicle": "us_m1a2_abrams_v2",
            "vehicle_new": "M1A2 Sep",
            "air_kills": 0,
            "ground_kills": 1,
            "assists": 0,
            "deaths": 0,
            "captures": 1,
            "score": 620
        }, {
            "uid": 66490901,
            "nick":
            "\u0412\u0435\u0440\u0442\u043e\u0448\u043b\u044e\u0445\u0430",
            "index": 13,
            "vehicle": "spitfire_lf_mk9e_weisman",
            "vehicle_new": "Weizman's Spitfire LF Mk.IXe",
            "air_kills": 0,
            "ground_kills": 0,
            "assists": 1,
            "deaths": 1,
            "captures": 0,
            "score": 269
        }]
    }
    team2 = {
        "squadron":
        "VCoM",
        "players": [{
            "uid": 116628911,
            "nick": "FrostChicken",
            "index": 0,
            "vehicle": "sw_leopard_2a6nl",
            "vehicle_new": "Leopard 2A6NL",
            "air_kills": 0,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 1,
            "captures": 0,
            "score": 80
        }, {
            "uid": 124076506,
            "nick": "AstroZeus6571@live",
            "index": 1,
            "vehicle": "saab_jas39c",
            "vehicle_new": "JAS39C",
            "air_kills": 1,
            "ground_kills": 0,
            "assists": 2,
            "deaths": 1,
            "captures": 0,
            "score": 336
        }, {
            "uid": 125095780,
            "nick": "HitNRunTitan@live",
            "index": 2,
            "vehicle": "f_15e",
            "vehicle_new": "F-15E",
            "air_kills": 0,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 1,
            "captures": 0,
            "score": 59
        }, {
            "uid": 134108969,
            "nick": "MoneylessMonkey",
            "index": 3,
            "vehicle": "us_m1a1_hc_abrams",
            "vehicle_new": "M1A1 HC",
            "air_kills": 0,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 1,
            "captures": 0,
            "score": 80
        }, {
            "uid": 136002299,
            "nick": "DaddyDabbin3112@live",
            "index": 4,
            "vehicle": "f_15e",
            "vehicle_new": "F-15E",
            "air_kills": 0,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 1,
            "captures": 0,
            "score": 60
        }, {
            "uid": 32616799,
            "nick": "ViRuSSoNy",
            "index": 10,
            "vehicle": "it_otomatic",
            "vehicle_new": "Otomatic",
            "air_kills": 0,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 1,
            "captures": 0,
            "score": 69
        }, {
            "uid": 7429595,
            "nick": "farfadet12",
            "index": 14,
            "vehicle": "ef_2000_fgr4",
            "vehicle_new": "FG-2000",
            "air_kills": 0,
            "ground_kills": 0,
            "assists": 0,
            "deaths": 1,
            "captures": 0,
            "score": 0
        }, {
            "uid": 91889275,
            "nick": "noel_nootje@psn",
            "index": 15,
            "vehicle": "il_merkava_mk_4m",
            "vehicle_new": "Tan-SAM Kai (TEL)",
            "air_kills": 0,
            "ground_kills": 1,
            "assists": 0,
            "deaths": 1,
            "captures": 0,
            "score": 366
        }]
    }
    BASE_DIR = Path(__file__).resolve().parent
    output_dir = BASE_DIR / "TEST_IN_ME"
    os.makedirs(output_dir, exist_ok=True)
    output_path = os.path.join(output_dir, "output.png")
    
    # Call the async create_scoreboard, which offloads work correctly
    await create_scoreboard(
        match_details={"utc_timestamp": "1746424038", "session_id": "4acb2a60017e0f6", "received_unix": 1746424428},
        winning_team="AVR",
        team1_details=team1,
        team2_details=team2,
        map_file="Abandoned FaCtory",
        output_path=output_path,
        bar_color="win",
        diffs=[],
        WL={},
        is_draw=False
    )

    img_dim = Image.open(output_path).size

    file_size_bytes = os.path.getsize(output_path)

    size = file_size_bytes / 1024
    unit = "KB"
    if size > 1024:
        size /= 1024
        unit = "MB"
    img_size = f"{size:.2f} {unit}"
    logging.info(f"Test complete. Check the generated image at {output_path} : Image Dimensions: {img_dim} : Image Size: {img_size}")


def profile_async(coro):
    """Run an async coroutine under cProfile and print the top 20 slowest calls.

    Args:
        coro: An awaitable coroutine object to profile (passed to asyncio.run).
    """
    pr = cProfile.Profile()
    pr.enable()
    try:
        asyncio.run(coro)
    finally:
        pr.disable()
        s = io.StringIO()
        ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
        ps.print_stats(20)  # show top 20 slowest
        print(f"\n[Profiler results]\n{s.getvalue()}")

if __name__ == "__main__":
    profile_async(test()) # run the test function with profiling
    #asyncio.run(test()) # run the test function normally without profiling
