"""
autologging.py

WebSocket auto-logging and session processing. Connects to the replay stream,
detects squadron battle matches, tracks player states across sessions, generates
scoreboards, and posts results to configured Discord channels.
"""

# Standard Library Imports
import asyncio
import gzip
import json
import logging
import os
import re
import time as time_module
import traceback
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

# Third-Party Library Imports
import aiofiles
import aiohttp
import aiosqlite
import discord

# Local Module Imports
from . import utils
from data_parser import LangTableReader
from shared_store import blacklisted_squadrons
from .game_api import get_point_diff
from .render_replay import load_gob_file, render_gob
from .health import record_game_processed, record_ws_message
from .receiver_bridge import publish_replay_batch
from .utils import t, lang_from_features
from .scoreboard import create_scoreboard
from .utils import (
    STORAGE_DIR,
    CACHE_DIR,
    replay_data_path,
    replay_session_dir,
    SQ_BATTLES_DB_PATH,
    SQUADRONS_DB_PATH,
    blacklisted_guilds,
    DEFAULT_FOOTER_CAT,
    compress_json,
    decompress_json,
    get_bot,
    norm,
    resolve_clans,
    resolve_pref_key,
    is_foreign_pref_entry,
    load_features,
    remove_guild_pref_notification,
    PREMIUM_ACTIVATION_TS,
    is_guild_entitled,
    get_guild_tier,
    refresh_entitled_guilds,
    tier_cap,
    tier_enforcement_active,
    tier_allows_wildcard,
    allowed_pref_keys_for,
    enabled_pref_keys_for,
    WILDCARD_KEYS,
)
from .wl import record_result, record_draw, get_standings


# ============================================================================
# MODULE STATE
# ============================================================================

_process_amount = 15
_is_running = False
_process_semaphore: Optional[asyncio.Semaphore] = None  # Initialized lazily

# Squadron-name → clan_id resolution cache used when persisting player_games_hist
# rows so each row carries a stable clan_id and survives a future squadron rename.
# Bounded TTL refresh — squadrons_data is the source of truth and renames are rare.
_PGH_CLAN_ID_CACHE: dict[str, int] = {}
_PGH_CLAN_ID_CACHE_AT: float = 0.0
_PGH_CLAN_ID_CACHE_TTL = 300.0


def _resolve_clan_id_for_pgh(short_or_long_name: str) -> Optional[int]:
    """Resolve a clan_id from short_name or long_name. Returns None on miss."""
    global _PGH_CLAN_ID_CACHE, _PGH_CLAN_ID_CACHE_AT
    import sqlite3 as _sqlite

    now = time_module.time()
    if now - _PGH_CLAN_ID_CACHE_AT > _PGH_CLAN_ID_CACHE_TTL:
        _PGH_CLAN_ID_CACHE = {}
        _PGH_CLAN_ID_CACHE_AT = now

    key = (short_or_long_name or "").lower()
    if not key or key == "unknown":
        return None
    if key in _PGH_CLAN_ID_CACHE:
        cached = _PGH_CLAN_ID_CACHE[key]
        return cached if cached >= 0 else None

    try:
        with _sqlite.connect(SQUADRONS_DB_PATH) as con:
            row = con.execute(
                "SELECT clan_id FROM squadrons_data "
                "WHERE LOWER(short_name) = ? OR LOWER(long_name) = ? LIMIT 1",
                (key, key),
            ).fetchone()
            cid = int(row[0]) if row and row[0] is not None else -1
    except Exception:
        cid = -1

    _PGH_CLAN_ID_CACHE[key] = cid
    return cid if cid >= 0 else None

_scoreboard_locks: dict[str, asyncio.Lock] = {}
_sent_channels_by_session: dict[str, set[int]] = {}
_scoreboard_cache: dict[tuple[str, str, str], str] = {}
_video_render_sem: asyncio.Semaphore = asyncio.Semaphore(2)  # Max 2 concurrent video renders

DEBUG_GUILD_FILTER: int | None = 0  # Set to None when no filter is required or 0 to turn off



def _get_semaphore() -> asyncio.Semaphore:
    """Get or create the process semaphore."""
    global _process_semaphore
    if _process_semaphore is None:
        _process_semaphore = asyncio.Semaphore(_process_amount)
    return _process_semaphore

# ============================================================================
# Module Level DB init 
# ============================================================================

_DB_INIT_LOCK = asyncio.Lock()
_DB_INITIALIZED = False

CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS player_games_hist (
    UID TEXT NOT NULL,
    nick TEXT NOT NULL,
    squadron_name TEXT NOT NULL,
    squadron_tagged TEXT NOT NULL,
    session_id TEXT NOT NULL,
    vehicle TEXT,
    vehicle_internal TEXT,
    ground_kills INTEGER NOT NULL DEFAULT 0,
    air_kills INTEGER NOT NULL DEFAULT 0,
    assists INTEGER NOT NULL DEFAULT 0,
    captures INTEGER NOT NULL DEFAULT 0,
    deaths INTEGER NOT NULL DEFAULT 0,
    victor_bool TEXT NOT NULL DEFAULT 'Loss',
    endtime_unix INTEGER NOT NULL DEFAULT 0,
    clan_id INTEGER,
    UNIQUE (UID, session_id, vehicle_internal)
)
"""

INDEXES_SQL = [
    "CREATE INDEX IF NOT EXISTS idx_player_games_hist_session ON player_games_hist(session_id)",
    "CREATE INDEX IF NOT EXISTS idx_player_games_hist_uid_time ON player_games_hist(UID, endtime_unix)",
    "CREATE INDEX IF NOT EXISTS idx_player_games_hist_squad_time ON player_games_hist(squadron_tagged, endtime_unix)",
    "CREATE INDEX IF NOT EXISTS idx_player_games_hist_squadron_name ON player_games_hist(squadron_name, endtime_unix)",
    "CREATE INDEX IF NOT EXISTS idx_player_games_hist_endtime ON player_games_hist(endtime_unix)",
    "CREATE INDEX IF NOT EXISTS idx_player_games_hist_nick ON player_games_hist(nick COLLATE NOCASE)",
    # Composite index for the self-join in gather_player_frequent_teammate
    # (session_id, squadron_name) — without this the planner falls back to a
    # wide squadron_name scan and the recap call takes >1s.
    "CREATE INDEX IF NOT EXISTS idx_player_games_hist_session_squadron ON player_games_hist(session_id, squadron_name)",
]

UPSERT_SQL = """
INSERT INTO player_games_hist
  (UID, nick, squadron_name, squadron_tagged, session_id, vehicle, vehicle_internal,
   ground_kills, air_kills, assists, captures, deaths,
   victor_bool, endtime_unix, clan_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(UID, session_id, vehicle_internal) DO UPDATE SET
  nick=excluded.nick,
  squadron_name=excluded.squadron_name,
  squadron_tagged=excluded.squadron_tagged,
  vehicle=excluded.vehicle,
  ground_kills=excluded.ground_kills,
  air_kills=excluded.air_kills,
  assists=excluded.assists,
  captures=excluded.captures,
  deaths=excluded.deaths,
  victor_bool=excluded.victor_bool,
  endtime_unix=excluded.endtime_unix,
  clan_id=COALESCE(excluded.clan_id, player_games_hist.clan_id)
"""

async def init_players_db(conn: aiosqlite.Connection):
    """Idempotent: safe to call every time; only does real work once."""
    global _DB_INITIALIZED
    if _DB_INITIALIZED:
        return

    async with _DB_INIT_LOCK:
        if _DB_INITIALIZED:
            return

        # Pragmas
        await conn.execute("PRAGMA journal_mode=WAL;")
        await conn.execute("PRAGMA synchronous=NORMAL;")
        await conn.execute("PRAGMA busy_timeout=5000;")
        await conn.execute("PRAGMA temp_store=MEMORY;")

        await conn.execute(CREATE_TABLE_SQL)

        # Migrations
        cols = {row[1] for row in await conn.execute_fetchall("PRAGMA table_info(player_games_hist)")}
        if "victor_bool" not in cols:
            await conn.execute("ALTER TABLE player_games_hist ADD COLUMN victor_bool TEXT NOT NULL DEFAULT 'Loss'")
        if "endtime_unix" not in cols:
            await conn.execute("ALTER TABLE player_games_hist ADD COLUMN endtime_unix INTEGER NOT NULL DEFAULT 0")
        if "squadron_name" not in cols:
            await conn.execute("ALTER TABLE player_games_hist ADD COLUMN squadron_name TEXT NOT NULL DEFAULT 'UNKNOWN'")
        if "squadron_tagged" not in cols:
            await conn.execute("ALTER TABLE player_games_hist ADD COLUMN squadron_tagged TEXT NOT NULL DEFAULT 'UNKNOWN'")
        if "clan_id" not in cols:
            await conn.execute("ALTER TABLE player_games_hist ADD COLUMN clan_id INTEGER")

        # Indexes for read performance
        for sql in INDEXES_SQL:
            await conn.execute(sql)
        # clan_id index added by the clan_id migration; ensure it exists for fresh installs.
        await conn.execute(
            "CREATE INDEX IF NOT EXISTS idx_pgh_clanid_endtime ON player_games_hist(clan_id, endtime_unix)"
        )

        await conn.commit()
        _DB_INITIALIZED = True

# ===========================
# CACHE for DB
# ===========================

_TRANSLATE = None

def get_translator():
    """Return a cached English LangTableReader singleton.

    Returns:
        LangTableReader: Shared translator instance for English vehicle names.
    """
    global _TRANSLATE
    if _TRANSLATE is None:
        _TRANSLATE = LangTableReader("English")
    return _TRANSLATE

# ============================================================================
# TIME WINDOW
# ============================================================================



# ============================================================================
# APERF HELPERS
# ============================================================================



# ============================================================================
# UTILITY FUNCTIONS
# ============================================================================

def ensure_squadrons_path() -> Path:
    """Ensure the configured HC storage root exists and return the SQUADRONS.json path.

    Returns:
        Path: Absolute path to SQUADRONS.json (file may not yet exist).
    """
    STORAGE_DIR.mkdir(parents=True, exist_ok=True)
    return STORAGE_DIR / "SQUADRONS.json"


async def load_json_async(path: Path, default):
    """Async version of load_json for use in autologging."""
    try:
        async with aiofiles.open(path, "r", encoding="utf-8") as f:
            return json.loads(await f.read())
    except FileNotFoundError:
        return default


async def load_or_init_json(path: Path, default):
    """Load JSON from *path*, falling back to *default* on corruption.

    If the file is corrupt (invalid JSON or OS error), the file is deleted
    and a copy of *default* is returned.

    Args:
        path: Path to the JSON file.
        default: Fallback value returned when the file is missing or corrupt.

    Returns:
        Parsed JSON content, or a copy of *default* on failure.
    """
    try:
        return await load_json_async(path, default)
    except (json.JSONDecodeError, OSError):
        logging.warning(f"Corrupt JSON at {path!r}, resetting.")
        try:
            path.unlink()
        except OSError:
            pass
        return default.copy() if isinstance(default, dict) else default


def parse_channel_id(raw: str) -> int | None:
    """
    Given a raw prefs string, return an int channel ID or None if invalid/disabled.
    """
    if not raw:
        return None

    # If the word "DISABLED" appears anywhere, treat as explicitly off
    if "DISABLED" in raw.upper():
        return None

    # Search for a 17-19 digit sequence
    m = re.search(r"\b(\d{17,19})\b", raw)
    if not m:
        return None

    return int(m.group(1))


# ── Over-cap warning (tier enforcement) ──────────────────────────────────────


async def send_over_cap_warning(
    channel: discord.abc.Messageable,
    lang: str,
    tier: Optional[str],
    notif_type: str,
    squadron: str,
    *,
    reason: str = "over_cap",
) -> None:
    """Send the orange upgrade-warning embed in place of a normal notification.

    Fires once per dropped game — every over-cap event gets its own embed so the
    guild can see exactly which game was not logged due to the tier limit.
    `reason='wildcard_blocked'` swaps to the wildcard-specific copy.
    """
    guild_id = getattr(getattr(channel, "guild", None), "id", 0)

    cap = tier_cap(tier, notif_type)
    cap_str = "∞" if cap is None else str(cap)
    tier_label = (tier or "none").title()

    if reason == "wildcard_blocked":
        title = t(lang, "autolog.wildcard_blocked_title")
        desc = t(lang, "autolog.wildcard_blocked_desc", tier=tier_label, notif=notif_type)
    else:
        title = t(lang, "autolog.over_cap_title")
        desc = t(
            lang,
            "autolog.over_cap_desc",
            tier=tier_label,
            notif=notif_type,
            cap=cap_str,
            squadron=squadron,
        )

    embed = discord.Embed(title=title, description=desc, color=discord.Color.orange())
    embed.set_footer(text=t(lang, "autolog.over_cap_footer"))
    try:
        await channel.send(embed=embed)
    except Exception as e:
        logging.warning(f"[TIER] Failed to send over-cap warning to {guild_id}: {e}")


def minutes_ago(ts, now=None):
    """Format a Unix timestamp as a human-readable relative time string.

    Args:
        ts: Unix timestamp (seconds) of the past event.
        now: Current Unix timestamp. Defaults to ``time.time()``.

    Returns:
        str: e.g. ``"3 minutes ago"`` or ``"1 minute ago"`` (minimum 1 minute).
    """
    now = now if now is not None else int(time_module.time())
    mins = max(1, (now - ts) // 60)
    return f"{mins} minute{'s' if mins != 1 else ''} ago"


def load_replay_data_from_disk(session_id: str):
    """Load replay_data.json.gz from disk for a session."""
    path = replay_data_path(session_id)
    if path.is_file():
        with gzip.open(path, "rt", encoding="utf-8") as f:
            return json.load(f)
    return None


# ============================================================================
# WEBSOCKET HANDLERS
# ============================================================================

async def handle_ws_replays(replays: list[dict]):
    """
    Process incoming WebSocket replay data.
    Called sequentially by ws_replay_listener's queue processor.
    No _is_running guard needed - queue ensures sequential processing.
    """
    try:
        await process_ws_replays(replays)
        await record_ws_message("sqb_autolog")
    except Exception as e:
        logging.error(f"Error in process_ws_replays: {e}")
        logging.error(traceback.format_exc())


async def process_ws_replays(replays: list[dict]):
    """
    Process replays received via WebSocket.
    Validates in-memory before writing to disk, uses file existence for dedup.
    """
    bot = get_bot()
    now_ts = int(time_module.time())

    # Load squadrons data
    squadrons_path = ensure_squadrons_path()
    squadrons_json = await load_or_init_json(squadrons_path, {})
    if not isinstance(squadrons_json, dict):
        squadrons_json = {}

    # Transform, validate in-memory, and save only valid replays
    validated_games = []
    forwarded_replays: list[dict[str, Any]] = []
    for replay in replays:
        # Only process squadron battle games (new format may omit type since WS is SQB-specific)
        replay_type = replay.get("type")
        if replay_type is not None and replay_type != "sqb":
            logging.info(f"[WS] Skipping non-sqb replay (type={replay_type!r})")
            continue

        # Transform to local format first - it handles _id -> hex conversion
        wrapped = {'completed': [replay]}
        local_data = utils.transform_to_local_format(wrapped)
        if not local_data:
            logging.warning(f"[WS] Failed to transform replay")
            continue

        hex_id = local_data.get("session_id_hex", "")
        if not hex_id:
            continue

        # Skip if already processed
        replay_dir = replay_session_dir(hex_id)
        if replay_data_path(hex_id).exists():
            continue

        # Validate in-memory (fail fast - don't write invalid data to disk)
        teams = local_data.get("teams", [])
        squadrons = local_data.get("squadrons", [])
        winning_sq = local_data.get("winning_team_squadron", "")
        losing_sq = local_data.get("losing_team_squadron", "")

        if not teams or len(teams) < 2:
            logging.warning(f"[WS] Invalid replay {hex_id}: Missing teams")
            continue
        if not all(team.get("players") for team in teams):
            logging.warning(f"[WS] Invalid replay {hex_id}: Teams have no players")
            continue
        if not squadrons or all(sq == "" for sq in squadrons):
            logging.warning(f"[WS] Invalid replay {hex_id}: Empty squadron names")
            continue
        if not winning_sq or not losing_sq:
            logging.warning(f"[WS] Invalid replay {hex_id}: Missing winner/loser")
            continue

        # Replay is valid - save to disk
        replay_dir = replay_session_dir(hex_id)
        replay_dir.mkdir(parents=True, exist_ok=True)
        replay_file = replay_dir / "replay_data.json.gz"

        try:
            raw = json.dumps(local_data, ensure_ascii=False).encode("utf-8")
            compressed = await asyncio.to_thread(gzip.compress, raw)
            async with aiofiles.open(replay_file, "wb") as f:
                await f.write(compressed)
            logging.info(f"[WSS] Saved {hex_id} ({len(compressed)} bytes compressed)")
        except Exception as e:
            logging.error(f"[WSS] Failed to save replay {hex_id}: {e}")
            continue

        scoreboard_context = await build_scoreboard_context(
            hex_id,
            local_data,
            received_time=now_ts,
            end_time=int(replay.get("end_ts") or now_ts),
        )
        local_data["scoreboard_context"] = scoreboard_context

        forwarded_replays.append(local_data)
        validated_games.append({
            "sessionIdHex": hex_id,
            "endTime": int(replay.get("end_ts") or now_ts),
            "missionName": local_data.get("map", ""),
            "receivedTime": now_ts,
            "scoreboard_context": scoreboard_context,
        })

    if not validated_games:
        return

    # Record game count for health dashboard
    for _ in validated_games:
        record_game_processed()

    # Update comps/stats first so /comp and stats queries see new data immediately
    await process_comps(validated_games)
    await process_stats(validated_games)

    # Build guild mappings and dispatch scoreboards to Discord
    EVERYTHING_LOGS = True
    hex_plus = await build_hex_plus_guild(validated_games, EVERYTHING_LOGS)
    await refresh_entitled_guilds()
    await prune_invalid_channels(bot, hex_plus)
    await dispatch_processing(hex_plus, squadrons_json)

    await process_match_summaries(validated_games)

    try:
        await publish_replay_batch(forwarded_replays)
    except Exception as e:
        logging.warning(f"[BRIDGE] Failed to forward replay batch: {e}")



# ============================================================================
# GUILD MAPPING AND DISPATCH
# ============================================================================

async def build_hex_plus_guild(
    games: List[Dict[str, Any]],
    WILDCARD_BOOL: bool
) -> Dict[str, Tuple[Dict[str, Any], List[Tuple[Any, Any]]]]:
    """
    For each game, read its replay_data.json, resolve long_clan names, then
    match those against each guild's prefs to decide where to send logs.

    Optimized: Pre-builds lookup tables from guild prefs, then matches games
    against them. O(guilds + games) instead of O(guilds * games).
    """
    bot = get_bot()
    mapping: Dict[str, Tuple[Dict[str, Any], List[Tuple[Any, Any]]]] = {}

    # Build lookups for the prefs "Short"/"Long" refresh below.
    long2short: Dict[str, str] = {}
    clanid2short: Dict[str, str] = {}
    clanid2long: Dict[str, str] = {}
    try:
        async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
            async with db.execute(
                "SELECT clan_id, long_name, short_name FROM squadrons_data WHERE long_name IS NOT NULL AND short_name IS NOT NULL"
            ) as cursor:
                async for row in cursor:
                    cid, ln, sn = row
                    if ln and sn:
                        long2short[ln.strip().lower()] = sn
                    if cid is not None and sn:
                        clanid2short[str(cid)] = sn
                    if cid is not None and ln:
                        clanid2long[str(cid)] = ln
    except Exception:
        pass

    # PHASE 1: Load all guild prefs and build lookup tables (async, with yields)
    # Each indexed entry carries (guild, chan, key, flag) where flag is:
    #   "ok"               → in-cap, dispatch as normal
    #   "over_cap"         → enabled but exceeds tier cap, send over-cap warning
    #   "wildcard_blocked" → wildcard enabled on tier that doesn't allow wildcards
    squadron_to_guilds: Dict[str, List[Tuple[Any, str, str, str]]] = {}
    wildcard_guilds: List[Tuple[Any, str, str, str]] = []
    prefs_to_save: List[Tuple[int, dict]] = []

    for i, guild in enumerate(bot.guilds):
        # Yield every 50 guilds to keep event loop responsive
        if i > 0 and i % 50 == 0:
            await asyncio.sleep(0)

        prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
        try:
            async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
                prefs = json.loads(await fp.read())
        except FileNotFoundError:
            continue
        except Exception:
            continue

        if not prefs:
            continue

        # Refresh display fields (Short, Long) on each entry. Keys may be a
        # clan_id (post-migration) or a long_name (legacy), so check both maps.
        updated = False
        for key in prefs.keys():
            entry = prefs.get(key)
            if not isinstance(entry, dict):
                continue
            key_str = str(key)
            short_val: Optional[str] = None
            long_val: Optional[str] = None
            if key_str.isdigit() and key_str in clanid2short:
                short_val = clanid2short[key_str]
                long_val = clanid2long.get(key_str)
            else:
                key_lc = key_str.strip().lower()
                if key_lc in long2short:
                    short_val = long2short[key_lc]
                    long_val = key_str  # the key itself is the long_name
            if short_val and entry.get("Short") != short_val:
                entry["Short"] = short_val
                updated = True
            if long_val and entry.get("Long") != long_val:
                entry["Long"] = long_val
                updated = True

        if updated:
            prefs_to_save.append((guild.id, prefs))

        # Resolve tier + enabled/allowed sets for Logs
        tier = await get_guild_tier(guild.id)
        enabled_logs = set(enabled_pref_keys_for(prefs, "Logs"))
        allowed_logs = allowed_pref_keys_for(prefs, tier, "Logs")
        over_cap_logs = enabled_logs - allowed_logs
        enforcement = tier_enforcement_active()

        # Index wildcard prefs
        if WILDCARD_BOOL:
            wildcard = next((k for k in prefs if k.lower() in WILDCARD_KEYS), None)
            if wildcard:
                chan = prefs[wildcard].get("Logs")
                if chan and "DISABLED" not in str(chan).upper():
                    if wildcard in allowed_logs:
                        wildcard_guilds.append((guild, chan, wildcard, "ok"))
                    elif enforcement and wildcard in over_cap_logs:
                        # Dropped by wildcard gate specifically (standard tier)
                        flag = "wildcard_blocked" if not tier_allows_wildcard(tier) else "over_cap"
                        wildcard_guilds.append((guild, chan, wildcard, flag))

        # Index squadron prefs.
        # Keys may be a numeric clan_id (post-clan_id-migration), a long_name
        # (legacy / orphan), or a short_name. The replay matcher below works
        # against normalized short/long names, so each pref entry is registered
        # under all of its known names so it can be found regardless of which
        # form ends up in the replay JSON.
        for key, cfg in prefs.items():
            if not isinstance(cfg, dict) or is_foreign_pref_entry(cfg):
                continue
            chan = cfg.get("Logs")
            if not chan or "DISABLED" in str(chan).upper():
                continue
            key_norm = norm(key)
            if not key_norm or key_norm in {"*", "all", "everything", "global"}:
                continue

            match_norms: set[str] = set()
            resolved = await resolve_pref_key(key, cfg)
            if resolved:
                if resolved.get("long_name"):
                    match_norms.add(norm(resolved["long_name"]))
                if resolved.get("short_name"):
                    match_norms.add(norm(resolved["short_name"]))
                if resolved.get("tag_name"):
                    match_norms.add(norm(resolved["tag_name"]))
            if not match_norms:
                # Couldn't resolve — fall back to raw key (covers orphaned
                # historical prefs where the squadron isn't in squadrons_data).
                match_norms.add(key_norm)

            for mn in match_norms:
                if not mn:
                    continue
                if key in allowed_logs:
                    squadron_to_guilds.setdefault(mn, []).append((guild, chan, key, "ok"))
                elif enforcement and key in over_cap_logs:
                    squadron_to_guilds.setdefault(mn, []).append((guild, chan, key, "over_cap"))

    # Batch save updated prefs (async)
    for guild_id, prefs in prefs_to_save:
        prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild_id}-preferences.json"
        async with aiofiles.open(prefs_path, "w", encoding="utf-8") as fp:
            await fp.write(json.dumps(prefs))


    # PHASE 2: Match games against pre-built lookup tables
    blacklisted_squad_norms = {norm(bl) for bl in blacklisted_squadrons()}

    for g in games:
        sid = g.get("sessionIdHex", "")
        replay_path = replay_data_path(sid)

        try:
            raw = await asyncio.to_thread(replay_path.read_bytes)
            replay_data = json.loads(gzip.decompress(raw))
        except Exception:
            logging.error(f"SESSION HEX {sid} FAILED TO GET REPLAY DATA")
            mapping[sid] = (g, [])
            continue

        squads = replay_data.get("squadrons", [])
        tags = replay_data.get("squadrons_tagged", [])
        if not squads:
            logging.error(f"SESSION HEX {sid} HAS NO SQUADRONS")
            mapping[sid] = (g, [])
            continue

        # Short names come directly from the replay
        squad_shorts = {norm(s) for s in squads}

        # Resolve to get long names for matching prefs keyed by long name
        resolved = await resolve_clans(shorts=squads, tags=tags)
        squad_longs = {norm(c["long_name"]) for c in resolved if c["long_name"] != "<unresolved>"}

        all_squad_names = squad_shorts | squad_longs

        # Match against indexed guilds
        # Dedupe by (guild.id, channel_id). If the same (guild, channel) matches via both
        # an allowed ("ok") key and an over-cap key, the "ok" entry wins — we only want to
        # fire the warning when NO in-cap squadron for this game matches this channel.
        targets_by_key: dict[tuple[int, int], tuple] = {}

        def _merge(guild, chan, flag, squadron_key):
            chan_id = parse_channel_id(str(chan))
            if not chan_id:
                return
            k = (guild.id, chan_id)
            existing = targets_by_key.get(k)
            if existing is None:
                targets_by_key[k] = (guild, chan, flag, squadron_key)
                return
            # "ok" wins over any warning flag
            if existing[2] != "ok" and flag == "ok":
                targets_by_key[k] = (guild, chan, flag, squadron_key)

        # Check wildcard guilds
        for guild, chan, wkey, flag in wildcard_guilds:
            _merge(guild, chan, flag, wkey)

        # Check squadron matches — skip blacklisted squadron keys
        for squad_norm in all_squad_names:
            if squad_norm in blacklisted_squad_norms:
                continue
            for guild, chan, key, flag in squadron_to_guilds.get(squad_norm, []):
                _merge(guild, chan, flag, key)

        mapping[sid] = (g, list(targets_by_key.values()))

    return mapping


async def prune_invalid_channels(bot, mapping: dict[str, tuple[dict, list[tuple]]]):
    """
    Drop any guild-target whose prefs string doesn't parse to a valid channel ID, or whose channel no longer exists.
    Drop any guild-target whose prefs string parse to a channel ID inside a blacklisted server.
    Remove whole sessions that lose all targets.

    Targets are (guild, raw_chan, flag, squadron_key) — flag is preserved through
    pruning so the dispatch step can decide between a real scoreboard and an
    over-cap/wildcard-blocked warning.
    """
    blacklist = blacklisted_guilds()

    for sid in list(mapping):
        game, targets = mapping[sid]
        valid_targets: list[tuple] = []

        for entry in targets:
            # Backward-compat: older callers may pass (guild, raw_chan) 2-tuples.
            if len(entry) == 4:
                guild, raw_chan, flag, squadron_key = entry
            else:
                guild, raw_chan = entry[0], entry[1]
                flag, squadron_key = "ok", ""

            chan_id = parse_channel_id(raw_chan)
            if chan_id is None:
                if squadron_key:
                    await remove_guild_pref_notification(guild.id, squadron_key, "Logs")
                continue

            # Drop any target from a blacklisted server
            if guild.id in blacklist:
                continue

            # Check channel exists in Discord
            channel = bot.get_channel(chan_id)
            if channel is None:
                try:
                    channel = await bot.fetch_channel(chan_id)
                except discord.NotFound:
                    if squadron_key:
                        await remove_guild_pref_notification(guild.id, squadron_key, "Logs")
                    continue
                except discord.Forbidden:
                    continue

            # Premium gate — notify and skip guilds without an active subscription
            if not await is_guild_entitled(guild.id):
                logging.info(f"[PREMIUM] Skipping guild {guild.id} — no active entitlement")
                try:
                    _gf = await load_features(guild.id)
                    _lang = lang_from_features(_gf)
                    embed = discord.Embed(
                        title=t(_lang, "autolog.game_not_logged_title"),
                        description=t(_lang, "autolog.game_not_logged_desc"),
                        color=discord.Color.red(),
                    )
                    await channel.send(embed=embed)
                except Exception:
                    pass
                continue

            valid_targets.append((guild, chan_id, flag, squadron_key))

        if valid_targets:
            mapping[sid] = (game, valid_targets)
        else:
            mapping.pop(sid)


async def dispatch_processing(
    mapping: dict[str, tuple[dict, list[tuple]]],
    squadrons_json: dict
):
    """
    Process each (game, guild) session concurrently.
    Concurrency is governed by the process semaphore.
    """
    bot = get_bot()
    semaphore = _get_semaphore()

    # Flatten out all (game, guild, channel_id, flag, squadron_key) tuples
    sessions = []
    for _, (game, targets) in mapping.items():
        for entry in targets:
            if len(entry) == 4:
                guild, channel_id, flag, squadron_key = entry
            else:
                guild, channel_id = entry[0], entry[1]
                flag, squadron_key = "ok", ""
            sessions.append((game, guild, channel_id, flag, squadron_key))

    total = len(sessions)
    if total == 0:
        return

    async def _worker(game, guild, channel_id, flag, squadron_key):
        sid = game.get("sessionIdHex", "")
        mission_name = game.get("missionName", "")
        end_time = game.get("endTime", "")
        received_time = game.get("receivedTime")

        # Tier over-cap paths: send the orange warning in place of the scoreboard.
        if flag in ("over_cap", "wildcard_blocked"):
            async with semaphore:
                try:
                    channel = bot.get_channel(channel_id)
                    if channel is None:
                        channel = await bot.fetch_channel(channel_id)
                    if not isinstance(channel, discord.abc.Messageable):
                        return
                    _gf = await load_features(guild.id)
                    _lang = lang_from_features(_gf)
                    tier = await get_guild_tier(guild.id)
                    # Resolve the pref key to a readable squadron name. Post-clan_id
                    # migration the key is the numeric clan_id, which would otherwise
                    # show as a bare ID in the embed.
                    display_squadron = squadron_key
                    try:
                        prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
                        async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
                            _prefs = json.loads(await fp.read())
                        _entry = _prefs.get(squadron_key) if isinstance(_prefs, dict) else None
                        if isinstance(_entry, dict):
                            display_squadron = _entry.get("Long") or _entry.get("Short") or squadron_key
                    except Exception:
                        pass
                    await send_over_cap_warning(
                        channel, _lang, tier, "Logs", display_squadron, reason=flag
                    )
                except Exception as err:
                    logging.warning(f"[TIER] Over-cap warn failed {guild.id}/{channel_id}: {err}")
            return

        # Normal scoreboard path
        prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
        try:
            async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
                guild_prefs = json.loads(await fp.read())
        except Exception:
            guild_prefs = {}
        async with semaphore:
            try:
                await process_session(
                    bot,
                    guild.id,
                    guild.name,
                    channel_id,
                    sid,
                    mission_name,
                    end_time,
                    squadrons_json,
                    guild_prefs,
                    received_time=received_time,
                    session_context=game.get("scoreboard_context"),
                )
            except asyncio.CancelledError:
                raise
            except Exception as err:
                logging.error(f"Error processing {sid} for {guild.id}: {err}", exc_info=True)

    tasks = [
        asyncio.create_task(_worker(game, guild, channel_id, flag, squadron_key))
        for game, guild, channel_id, flag, squadron_key in sessions
    ]
    await asyncio.gather(*tasks, return_exceptions=True)


# ============================================================================
# SESSION PROCESSING
# ============================================================================

async def process_session(
    bot,
    guild_id: int,
    guild_name: str,
    squadron_pref,
    session_id: str,
    map_name: str,
    timestamp: int,
    squadrons_data: dict,
    guild_prefs: dict,
    received_time: Optional[int] = None,
    session_context: Optional[dict[str, Any]] = None,
):
    """Process a single game session: build scoreboard and send it to Discord.

    Loads replay data from disk, translates vehicle names, resolves clan names,
    records W/L results, generates a scoreboard image (cached per session/color/
    language), deduplicates per channel, and sends the image with interactive
    buttons. Appends a premium upsell embed for non-entitled guilds.

    Args:
        bot: The Discord bot instance.
        guild_id: Discord guild ID receiving the scoreboard.
        guild_name: Human-readable guild name (for logging).
        squadron_pref: Raw channel-ID preference string for the guild.
        session_id: Hex session ID of the replay.
        map_name: Display name of the map played.
        timestamp: UTC Unix timestamp of the match.
        squadrons_data: Mapping of guild IDs to squadron metadata.
        guild_prefs: Full guild preferences dict (passed to points diffs).
    """
    # Load replay JSON
    base_dir = replay_session_dir(session_id)
    replay_path = replay_data_path(session_id)
    try:
        raw = await asyncio.to_thread(replay_path.read_bytes)
        replay_data = json.loads(gzip.decompress(raw))
    except FileNotFoundError:
        logging.error(f"Replay file not found for session ID {session_id}")
        return
    except (OSError, json.JSONDecodeError) as e:
        logging.error(f"Replay file for session ID {session_id} is invalid: {e}")
        return

    # Extract winner/loser/draw — strip tag decorators so bare short names
    # (e.g. "DSPLA" stored in SQUADRONS.json) match raw replay values like "-DSPLA-".
    def _strip_tag(s: Optional[str]) -> Optional[str]:
        if s is None:
            return None
        return re.sub(r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$', '', s) or s

    squadrons = replay_data.get("squadrons", [])
    winner = _strip_tag(replay_data.get("winning_team_squadron"))
    is_draw = replay_data.get("draw", False)
    loser_candidates = [_strip_tag(sq) for sq in squadrons if _strip_tag(sq) != winner]
    loser = loser_candidates[0] if loser_candidates else None
    # Guild-specific squadron (short name)
    guild_data = squadrons_data.get(str(guild_id), {})
    guild_squadron = guild_data.get("SQ_ShortHand_Name")

    # Translation setup. lang/units.csv columns are stored with literal
    # angle brackets (e.g. ``<Russian>``) — pass the column name through
    # unmodified so LangTableReader actually selects the right column.
    # The stripped form is kept only for filesystem-safe filenames below.
    guild_features = await load_features(guild_id)
    lang = lang_from_features(guild_features)
    language_column = guild_features.get("Language", "<English>")
    language = language_column.strip("<>")
    translate = LangTableReader(language_column)

    # Translate vehicles in memory
    for team in replay_data.get("teams", []):
        for player in team.get("players", []):
            vehicle = player.get("vehicle")
            if vehicle:
                translated = translate.get_translate(vehicle)
                player["vehicle_new"] = translated if translated else vehicle
            else:
                player["vehicle"] = "DISCONNECTED"
                player["vehicle_new"] = "DISCONNECTED"

    # Clan resolution — resolve each team independently to preserve index order.
    # Batching all teams into one resolve_clans call inverts results when one
    # team resolves via the short-name pass and another via the tag pass.
    for team in replay_data.get("teams", []):
        if not team:
            continue
        sq  = str(team.get("squadron") or "")
        tag = str(team.get("squadron_tagged") or "")
        if not sq and not tag:
            continue
        _results = await resolve_clans(shorts=[sq] if sq else [], tags=[tag] if tag else [])
        if _results:
            r = _results[0]
            if r.get("long_name") and r["long_name"] != "<unresolved>":
                team["squadron_long"] = r["long_name"]
            if r.get("short_name"):
                team["squadron_short"] = r["short_name"]

    # Prep scoreboard params
    if session_context and session_context.get("match_details"):
        match_details: Dict[str, Any] = dict(session_context["match_details"])
    else:
        match_details = {"utc_timestamp": str(timestamp), "session_id": session_id}
        if received_time is not None:
            match_details["received_unix"] = int(received_time)
        else:
            try:
                async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as _conn:
                    async with _conn.execute(
                        "SELECT received_unix FROM match_summary WHERE session_id = ?",
                        (session_id,),
                    ) as _cur:
                        _row = await _cur.fetchone()
                if _row and _row[0] is not None:
                    match_details["received_unix"] = int(_row[0])
            except Exception:
                pass
    if is_draw:
        bar_color = "draw"
    elif guild_squadron == winner:
        bar_color = "win"
    elif guild_squadron == loser:
        bar_color = "loss"
    elif guild_squadron is None:
        bar_color = "not_set"
    else:
        bar_color = "not_involved"

    os.makedirs(base_dir, exist_ok=True)
    output_path = base_dir / f"game_result-{bar_color}-{language}.png"
    cache_key = (session_id, bar_color, language)

    teams: List[Dict[str, Any]] = replay_data.get("teams", [])
    new_wl = session_context.get("wl", {}) if session_context else {}
    diffs = session_context.get("points_diffs", {}) if session_context else {}
    winner_tag: Optional[str] = session_context.get("winner") if session_context else replay_data.get("winning_team_squadron")
    if not winner_tag:
        w_idx = replay_data.get("winner")
        home_tag: Optional[str] = (teams[0] or {}).get("squadron") if len(teams) > 0 else None
        away_tag: Optional[str] = (teams[1] or {}).get("squadron") if len(teams) > 1 else None
        if w_idx in (0, 1):
            winner_tag = (home_tag, away_tag)[w_idx]

    if not new_wl:
        squadrons_clean: List[str] = [
            t for t in (
                (teams[0] or {}).get("squadron") if len(teams) > 0 else None,
                (teams[1] or {}).get("squadron") if len(teams) > 1 else None,
            )
            if t and t.strip() and t.upper() != "UNKNOWN"
        ]
        if is_draw and len(squadrons_clean) >= 2:
            try:
                new_wl = await record_draw(squadrons_clean, session_id)
            except Exception as e:
                logging.error(f"[W/L] record_draw failed ({session_id}): {e}")
                new_wl = get_standings(squadrons_clean)
        elif winner_tag and winner_tag in squadrons_clean and len(squadrons_clean) >= 2:
            try:
                new_wl = await record_result(winner_tag, squadrons_clean, session_id)
            except Exception as e:
                logging.error(f"[W/L] record_result failed ({session_id}): {e}")
                new_wl = get_standings(squadrons_clean)
        else:
            new_wl = get_standings(squadrons_clean)

    # Scoreboard Build
    lock = _scoreboard_locks.setdefault(session_id, asyncio.Lock())
    async with lock:
        if not output_path.exists():
            try:
                if not diffs:
                    diffs = await get_points_diffs(
                        session_id,
                        guild_id,
                        guild_name,
                        guild_prefs,
                        replay_data
                    )

                await create_scoreboard(
                    match_details,
                    winner,
                    teams[0] or {},
                    teams[1] or {},
                    map_name,
                    str(output_path),
                    bar_color,
                    diffs,
                    WL=new_wl,
                    is_draw=is_draw,
                )

            except Exception as e:
                logging.error(f"create_scoreboard failed: {e}")
                logging.error(traceback.format_exc())
                return

            if not output_path.exists():
                logging.error(f"Scoreboard image still missing: {output_path}")
                return

    # DE-DUPING: skip if already sent to this channel
    channel_id = parse_channel_id(str(squadron_pref))

    if channel_id is None:
        logging.warning(f"[PROCESS] Invalid channel ID format: {squadron_pref} for {guild_name} ({guild_id})")
        return

    sent_set = _sent_channels_by_session.setdefault(session_id, set())
    if channel_id in sent_set:
        logging.info(f"[SEND] Skipped {session_id} → {guild_name} ({channel_id}): already sent")
        return
    sent_set.add(channel_id)

    # SEND EMBED + IMAGE
    try:
        # FILE VALIDATION
        if not output_path.exists():
            logging.error(f"[SEND] Scoreboard file missing for {session_id}: {output_path}")
            return

        if output_path.stat().st_size == 0:
            logging.error(f"[SEND] Scoreboard file empty for {session_id}: {output_path}")
            return

        # CHANNEL VALIDATION
        channel = bot.get_channel(channel_id)
        if channel is None:
            try:
                channel = await bot.fetch_channel(channel_id)
            except discord.NotFound:
                logging.warning(f"[SEND] Channel not found for {session_id}: {channel_id} in {guild_name}")
                return
            except discord.Forbidden:
                logging.warning(f"[SEND] No access to channel for {session_id}: {channel_id} in {guild_name}")
                return

        USE_SCOREBOARD_CACHE = False

        # SEND WITH RETRY LOGIC
        max_retries = 3
        base_delay = 1

        # Check cache *only if allowed*
        if USE_SCOREBOARD_CACHE:
            cached_url = _scoreboard_cache.get(cache_key)
            if cached_url:
                view = build_scoreboard_view(guild_id, session_id, lang=lang)
                await channel.send(content=cached_url, view=view)
                return

        # Otherwise, first upload - normal send loop
        for attempt in range(max_retries):
            try:
                view = build_scoreboard_view(guild_id, session_id, lang=lang)

                with open(output_path, 'rb') as f:
                    msg = await channel.send(
                        file=discord.File(f, filename="game_result.png"),
                        view=view
                    )

                # Cache the file *only if caching is enabled*
                if USE_SCOREBOARD_CACHE and msg.attachments:
                    attachment = msg.attachments[0]
                    _scoreboard_cache[cache_key] = attachment.url

                logging.info(f"[SEND] Scoreboard sent for {session_id} → {guild_name} ({channel_id})")

                # Premium upsell for non-entitled guilds
                if not await is_guild_entitled(guild_id):
                    warn_embed = discord.Embed(
                        title=t(lang, "autolog.server_not_upgraded_title"),
                        description=t(lang, "autolog.server_not_upgraded_autolog_desc", deadline=PREMIUM_ACTIVATION_TS),
                        color=discord.Color.orange(),
                    )
                    warn_embed.set_footer(text=DEFAULT_FOOTER_CAT)
                    try:
                        await channel.send(embed=warn_embed)
                    except Exception as e:
                        logging.error("(AUTOLOG) Error sending premium warning: %s", e)

                return

            except (BrokenPipeError, ConnectionError, TimeoutError, aiohttp.ClientOSError, ConnectionResetError) as e:
                if attempt < max_retries - 1:
                    delay = base_delay * (2 ** attempt)
                    await asyncio.sleep(delay)
                    continue
                else:
                    logging.error(f"[SEND] Network failure after {max_retries} retries for {session_id} → {guild_name} ({channel_id}): {e}")
                    return

            except discord.HTTPException as e:
                logging.error(f"[SEND] Discord HTTP error for {session_id} → {guild_name} ({channel_id}): {e}")
                return

            except Exception as e:
                logging.error(f"[SEND] Unexpected error for {session_id} → {guild_name} ({channel_id}): {e}")
                return

    except Exception as e:
        logging.error(f"[SEND] Critical error for {session_id} → {guild_name} ({guild_id}): {e}")
        logging.error(traceback.format_exc())


def build_scoreboard_view(guild_id: int, session_id: str, lang: str = "en") -> discord.ui.View:
    """Create a Discord UI View with interactive buttons for a scoreboard message.

    Buttons included: View Replay (link), View on Website (link), View Video,
    View Log, and View Chat (only if a chat log exists in replay data).

    Args:
        guild_id: Discord guild ID.
        session_id: Hex session ID used to build the replay URL and callbacks.

    Returns:
        discord.ui.View: Persistent view with the assembled buttons.
    """
    view = discord.ui.View(timeout=None)
    session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"
    web_url = f"https://sre.pawjob.us/games/{session_id}"

    replay_button = discord.ui.Button(
        label=t(lang, "buttons.view_replay"),
        style=discord.ButtonStyle.link,
        url=session_url,
    )
    view.add_item(replay_button)

    website_button = discord.ui.Button(
        label=t(lang, "buttons.view_website"),
        style=discord.ButtonStyle.link,
        url=web_url,
        emoji="🌐",
    )
    view.add_item(website_button)

    video_button = discord.ui.Button(
        label=t(lang, "buttons.view_video"),
        style=discord.ButtonStyle.blurple,
        emoji="🎬",
    )

    async def on_video_click(interaction):
        await handle_view_video(interaction, session_id)

    video_button.callback = on_video_click
    view.add_item(video_button)

    battlelog_button = discord.ui.Button(
        label=t(lang, "buttons.view_log"),
        style=discord.ButtonStyle.green,
        emoji="📜",
    )

    async def on_battlelog_click(interaction):
        await handle_view_battlelog(interaction, session_id)

    battlelog_button.callback = on_battlelog_click
    view.add_item(battlelog_button)

    # Check if chat_log exists in replay_data
    replay_data = load_replay_data_from_disk(session_id)
    if replay_data and replay_data.get("chat_log"):
        chatlog_button = discord.ui.Button(
            label=t(lang, "buttons.view_chat"),
            style=discord.ButtonStyle.green,
            emoji="💬",
        )

        async def on_chatlog_click(interaction):
            await handle_view_chatlog(interaction, session_id)

        chatlog_button.callback = on_chatlog_click
        view.add_item(chatlog_button)

    return view


async def handle_view_video(interaction: discord.Interaction, session_id: str):
    """Callback for 'View Video' - renders replay JSON to MP4, sends ephemerally."""
    try:
        try:
            await interaction.response.defer(thinking=True, ephemeral=True)
        except Exception:
            return

        _gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
        _lang = lang_from_features(_gf)

        replay_dir = replay_session_dir(session_id)
        replay_json_path = replay_data_path(session_id)
        video_path = replay_dir / "replay_video.mp4"

        if not replay_json_path.exists():
            await interaction.followup.send(
                t(_lang, "autolog.replay_not_available"),
                ephemeral=True
            )
            return

        # Serve cached video if it exists
        if not video_path.exists():
            if _video_render_sem._value == 0:
                await interaction.followup.send(
                    t(_lang, "autolog.too_many_videos"),
                    ephemeral=True
                )
                return
            try:
                def _generate():
                    d = load_gob_file(replay_json_path)
                    render_gob(d, video_path)

                logging.info(f"REPLAY ({session_id}) RENDER START")
                async with _video_render_sem:
                    await asyncio.get_event_loop().run_in_executor(None, _generate)
                logging.info(f"REPLAY ({session_id}) RENDER END (Success)")
            except Exception as e:
                logging.info(f"REPLAY ({session_id}) RENDER END (Fail)")
                # Clean up broken/partial mp4 so it doesn't get cached
                if video_path.exists():
                    video_path.unlink(missing_ok=True)
                error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
                await interaction.followup.send(t(_lang, "autolog.video_gen_failed", error=error_str), ephemeral=True)
                return

        if not video_path.exists() or video_path.stat().st_size == 0:
            await interaction.followup.send(
                t(_lang, "autolog.video_missing"),
                ephemeral=True
            )
            return

        file_size = video_path.stat().st_size
        guild = interaction.guild
        max_size = guild.filesize_limit if guild else 25 * 1_048_576
        if file_size > max_size:
            file_mb = file_size / 1_048_576
            limit_mb = max_size / 1_048_576
            await interaction.followup.send(
                t(_lang, "autolog.video_too_large", file_mb=file_mb, limit_mb=limit_mb),
                ephemeral=True
            )
            return

        web_url = f"https://sre.pawjob.us/games/{session_id}"
        try:
            await interaction.followup.send(
                content=t(_lang, "autolog.video_web_fallback", url=web_url),
                file=discord.File(video_path),
                ephemeral=True,
            )
        except discord.HTTPException:
            # Upload failed (too large, rate limited, etc.) — fall back to web link
            await interaction.followup.send(
                content=t(_lang, "autolog.video_upload_failed", url=web_url),
                ephemeral=True,
            )

    except Exception as e:
        try:
            error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
            await interaction.followup.send(
                t("en", "autolog.video_unexpected_error", error=error_str),
                ephemeral=True
            )
        except:
            pass


async def handle_view_chatlog(interaction: discord.Interaction, session_id: str):
    """Callback for 'View Chat Log' - loads and displays chat_log from replay_data.json."""
    session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"

    try:
        try:
            await interaction.response.defer(thinking=True, ephemeral=True)
        except Exception:
            return

        _gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
        _lang = lang_from_features(_gf)

        replay_data = load_replay_data_from_disk(session_id)

        if replay_data is None:
            await interaction.followup.send(
                t(_lang, "autolog.replay_not_found", session_id=session_id),
                ephemeral=True
            )
            return

        # Check if chat_log exists
        chat_log = replay_data.get("chat_log", [])
        if not chat_log:
            await interaction.followup.send(
                t(_lang, "autolog.no_chat_log", session_id=session_id),
                ephemeral=True
            )
            return

        # Get winning and losing squadrons
        winning_squadron = replay_data.get("winning_team_squadron", "")
        losing_squadron = replay_data.get("losing_team_squadron", "")

        # Format chat log with diff syntax (green for winners, red for losers)
        formatted_lines = []

        # Pattern: [timestamp] [TEAM/ALL] [squadron] `username`: message
        pattern = r'\[([^\]]+)\]\s*\[([A-Z]+)\]\s*\[([^\]]+)\]\s*`([^`]+)`:\s*(.+)'

        for line in chat_log:
            match = re.match(pattern, line)
            if match:
                timestamp = match.group(1)
                scope = match.group(2)
                squadron = match.group(3)
                username = match.group(4)
                message = match.group(5)

                chat_scope_prefix = "T" if scope == "TEAM" else "A"

                # Determine prefix based on winning/losing team
                if squadron == winning_squadron:
                    prefix = "+"
                elif squadron == losing_squadron:
                    prefix = "-"
                else:
                    prefix = " "

                formatted_line = f"{prefix}[{timestamp}] [{chat_scope_prefix}] {f'[{squadron}]':<7} {username}: {message}"
                formatted_lines.append(formatted_line)
            else:
                formatted_lines.append(f"  {line}")

        chat_text = "\n".join(formatted_lines)

        # Discord message limit is 2000 chars, split if needed
        if len(chat_text) <= 1900:
            await interaction.followup.send(
                t(_lang, "autolog.chat_log_title", session_id=session_id, url=session_url) + f"\n```diff\n{chat_text}\n```",
                ephemeral=True
            )
        else:
            # Split into chunks
            chunks = []
            current_chunk = []
            current_length = 0

            for line in formatted_lines:
                line_length = len(line) + 1
                if current_length + line_length > 1800:
                    chunks.append("\n".join(current_chunk))
                    current_chunk = [line]
                    current_length = line_length
                else:
                    current_chunk.append(line)
                    current_length += line_length

            if current_chunk:
                chunks.append("\n".join(current_chunk))

            # Send first chunk with header
            await interaction.followup.send(
                t(_lang, "autolog.chat_log_part_title", session_id=session_id, url=session_url, part=1, total=len(chunks)) + f"\n```diff\n{chunks[0]}\n```",
                ephemeral=True
            )

            # Send remaining chunks
            for i, chunk in enumerate(chunks[1:], start=2):
                await interaction.followup.send(
                    t(_lang, "autolog.chat_log_part_only", part=i, total=len(chunks)) + f"\n```diff\n{chunk}\n```",
                    ephemeral=True
                )

    except Exception as e:
        try:
            error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
            await interaction.followup.send(
                t("en", "autolog.chat_log_error", error=error_str),
                ephemeral=True
            )
        except:
            pass


async def handle_view_battlelog(interaction: discord.Interaction, session_id: str):
    """Callback for 'Battle Log' - loads and displays events from replay_data.json."""
    session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"

    try:
        try:
            await interaction.response.defer(thinking=True, ephemeral=True)
        except Exception:
            return

        _gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
        _lang = lang_from_features(_gf)

        replay_data = load_replay_data_from_disk(session_id)

        if replay_data is None:
            await interaction.followup.send(
                t(_lang, "autolog.replay_not_found", session_id=session_id),
                ephemeral=True
            )
            return

        formatted_lines = replay_data.get("battle_log", [])
        if not formatted_lines:
            await interaction.followup.send(
                t(_lang, "autolog.no_battle_log", session_id=session_id),
                ephemeral=True
            )
            return

        log_text = "\n".join(formatted_lines)

        # Discord message limit is 2000 chars, split if needed
        if len(log_text) <= 1900:
            await interaction.followup.send(
                t(_lang, "autolog.battle_log_title", session_id=session_id, url=session_url) + f"\n```diff\n{log_text}\n```",
                ephemeral=True
            )
        else:
            chunks = []
            current_chunk = []
            current_length = 0

            for line in formatted_lines:
                line_length = len(line) + 1
                if current_length + line_length > 1800:
                    chunks.append("\n".join(current_chunk))
                    current_chunk = [line]
                    current_length = line_length
                else:
                    current_chunk.append(line)
                    current_length += line_length

            if current_chunk:
                chunks.append("\n".join(current_chunk))

            await interaction.followup.send(
                t(_lang, "autolog.battle_log_part_title", session_id=session_id, url=session_url, part=1, total=len(chunks)) + f"\n```diff\n{chunks[0]}\n```",
                ephemeral=True
            )

            for i, chunk in enumerate(chunks[1:], start=2):
                await interaction.followup.send(
                    t(_lang, "autolog.battle_log_part_only", part=i, total=len(chunks)) + f"\n```diff\n{chunk}\n```",
                    ephemeral=True
                )

    except Exception as e:
        try:
            error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
            await interaction.followup.send(
                t("en", "autolog.battle_log_error", error=error_str),
                ephemeral=True
            )
        except:
            pass


async def get_points_diffs(
    sid: str,
    guild_id: int,
    guild_name: str,
    guild_prefs: dict[str, Any],
    replay_data: dict[str, Any]
) -> dict[str, dict[str, Any]]:
    """
    Calls the PTS API for both teams in a replay and returns a structured diffs dict.
    Always processes both teams regardless of guild preferences.
    """

    diffs: dict[str, dict[str, Any]] = {}
    raw_teams = (replay_data.get("teams") or [])[:2]

    for team in raw_teams:
        if not team:
            continue

        long_name = team.get("squadron_long") or ""
        if not long_name:
            continue

        try:
            points_diff, diff_total, current_points = await get_point_diff(sid, team)
            diffs[long_name] = {
                "points_diff": points_diff,
                "diff_total": diff_total,
                "current_points": current_points,
            }
        except Exception as e:
            logging.error(f"Failed to get point diff for {long_name}: {e}")

    return diffs


async def build_scoreboard_context(
    session_id: str,
    replay_data: dict[str, Any],
    *,
    received_time: Optional[int] = None,
    end_time: Optional[int] = None,
) -> dict[str, Any]:
    """Build the scoreboard payload once per session.

    The result is reused by the Discord renderer and queued into the external
    websocket bridge so AXBot can render without reconstructing WL or point
    diff state from scratch.
    """
    teams: List[Dict[str, Any]] = list((replay_data.get("teams") or [])[:2])

    # Resolve each team independently and concurrently so the result is always
    # index-aligned with the teams list. Batching both teams into one
    # resolve_clans call can invert the order when one team resolves via the
    # short-name pass and the other falls through to the tag pass.
    async def _resolve_team(team: dict) -> Optional[dict]:
        sq  = str(team.get("squadron") or "")
        tag = str(team.get("squadron_tagged") or "")
        if not sq and not tag:
            return None
        results = await resolve_clans(shorts=[sq] if sq else [], tags=[tag] if tag else [])
        return results[0] if results else None

    resolved_teams = await asyncio.gather(*[_resolve_team(t) for t in teams])
    for team, result in zip(teams, resolved_teams):
        if team and result:
            if result.get("long_name") and result["long_name"] != "<unresolved>":
                team["squadron_long"] = result["long_name"]
            if result.get("short_name"):
                team["squadron_short"] = result["short_name"]

    home_tag: Optional[str] = (teams[0] or {}).get("squadron") if len(teams) > 0 else None
    away_tag: Optional[str] = (teams[1] or {}).get("squadron") if len(teams) > 1 else None

    def _clean(tag: Optional[str]) -> bool:
        return bool(tag and tag.strip() and tag.upper() != "UNKNOWN")

    squadrons_clean: List[str] = [t for t in (home_tag, away_tag) if _clean(t) and t is not None]

    winner_tag: Optional[str] = replay_data.get("winning_team_squadron")
    is_draw = bool(replay_data.get("draw", False))
    if not winner_tag:
        w_idx = replay_data.get("winner")
        if w_idx in (0, 1):
            winner_tag = (home_tag, away_tag)[w_idx]

    if is_draw and len(squadrons_clean) >= 2:
        try:
            wl = await record_draw(squadrons_clean, session_id)
        except Exception as e:
            logging.error(f"[W/L] record_draw failed ({session_id}): {e}")
            wl = get_standings(squadrons_clean)
    elif winner_tag and winner_tag in squadrons_clean and len(squadrons_clean) >= 2:
        try:
            wl = await record_result(winner_tag, squadrons_clean, session_id)
        except Exception as e:
            logging.error(f"[W/L] record_result failed ({session_id}): {e}")
            wl = get_standings(squadrons_clean)
    else:
        wl = get_standings(squadrons_clean)

    points_diffs = await get_points_diffs(session_id, 0, "", {}, replay_data)

    match_details: Dict[str, Any] = {
        "utc_timestamp": int(end_time or replay_data.get("end_ts") or 0),
        "session_id": session_id,
    }
    if received_time is not None:
        match_details["received_unix"] = int(received_time)

    return {
        "match_details": match_details,
        "winner": winner_tag,
        "is_draw": is_draw,
        "wl": wl,
        "points_diffs": points_diffs,
    }


# ============================================================================
# COMPANION PROCESSING
# ============================================================================

async def process_comps(new_games):
    """Track team composition updates for each squadron.

    For every game, reads replay data, translates vehicles, and compares the
    player roster against existing compositions stored in per-squadron JSON
    files under ``STORAGE_DIR / "COMPS"``. If a roster matches an existing
    comp (fewer than 2 additions and 2 removals), the comp is updated in place;
    otherwise a new comp entry is created.

    Args:
        new_games: List of game dicts, each containing ``sessionIdHex`` and
            ``endTime`` keys.
    """
    comp_dir = STORAGE_DIR / "COMPS"
    comp_dir.mkdir(parents=True, exist_ok=True)

    for game in new_games:
        session_id = game.get('sessionIdHex')
        endtime_raw = game.get('endTime')

        replay_path = replay_data_path(session_id)

        try:
            raw = await asyncio.to_thread(replay_path.read_bytes)
            replay_data = json.loads(gzip.decompress(raw))
        except FileNotFoundError:
            logging.warning(f"(COMP-WRITE) Replay file not found: {replay_path}")
            continue
        except (OSError, json.JSONDecodeError) as e:
            logging.warning(f"(COMP-WRITE) Invalid replay: {replay_path}: {e}")
            continue

        # Translate vehicle names
        translate = LangTableReader("English")
        for team in replay_data.get("teams", []):
            for p in team.get("players", []):
                veh = p.get("vehicle")
                if veh:
                    p["vehicle_new"] = translate.get_translate(veh)
                else:
                    p["vehicle"] = "DISCONNECTED"
                    p["vehicle_new"] = "DISCONNECTED"

        # Compare to existing comps and write new/updated ones
        for team in replay_data.get("teams", []):
            squadron = re.sub(r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$', '',
                              team.get("squadron", "UNKNOWN")).upper() or "UNKNOWN"
            squad_file = comp_dir / f"{squadron}.json"

            # Load existing comps_data (or start fresh)
            comps_data = {}
            if squad_file.exists():
                try:
                    async with aiofiles.open(squad_file, "r", encoding="utf-8") as f:
                        comps_data = json.loads(await f.read())
                except json.JSONDecodeError:
                    comps_data = {}

            # Build this game's player list
            players_list = [
                {
                    "UID": p["uid"],
                    "nick": (p.get("nick") or "").replace("coop/", "") or "Unknown",
                    "vehicle": p.get("vehicle_new") or "DISCONNECTED",
                    "vehicle_internal": p.get("vehicle") or "DISCONNECTED"
                }
                for p in team.get("players", [])
            ]
            new_uids = {p["UID"]: p for p in players_list}

            # Try to match an existing COMP by roster similarity
            matched_key = None
            added = set()
            removed = set()
            vehicle_diff = 0

            for comp_key, comp in comps_data.items():
                old_players = comp.get("Players", [])
                old_uids = {p["UID"]: p for p in old_players}

                added = set(new_uids) - set(old_uids)
                removed = set(old_uids) - set(new_uids)
                shared = set(new_uids) & set(old_uids)
                vehicle_diff = sum(
                    1 for uid in shared
                    if new_uids[uid]["vehicle"] != old_uids[uid]["vehicle"]
                )

                # Treat as same comp if fewer than 2 added and fewer than 2 removed
                if len(added) < 2 and len(removed) < 2:
                    matched_key = comp_key
                    break

            if matched_key:
                # Always update roster + timestamp when matched
                comps_data[matched_key]["Players"] = players_list
                comps_data[matched_key]["upd"] = endtime_raw
                async with aiofiles.open(squad_file, "w", encoding="utf-8") as f:
                    await f.write(json.dumps(comps_data, ensure_ascii=False, indent=2))
                logging.info(f"(COMP-WRITE) Updated {matched_key} for {squadron} | added={len(added)} removed={len(removed)} veh_diff={vehicle_diff} | upd={endtime_raw}")
                continue

            # No match => create a new COMP entry
            new_comp_key = f"COMP{len(comps_data) + 1}"
            comps_data[new_comp_key] = {
                "reg": endtime_raw,
                "upd": endtime_raw,
                "Players": players_list
            }
            async with aiofiles.open(squad_file, "w", encoding="utf-8") as f:
                await f.write(json.dumps(comps_data, ensure_ascii=False, indent=2))
            logging.info(f"(COMP-WRITE) Created {new_comp_key} for {squadron} | {len(players_list)} players | reg={endtime_raw}")


def as_int(x, default=0):
    """Safely convert *x* to int, returning *default* on failure.

    Args:
        x: Value to convert.
        default: Returned when conversion raises TypeError or ValueError.

    Returns:
        int: Converted value or *default*.
    """
    try:
        return int(x)
    except (TypeError, ValueError):
        return default


async def process_stats(new_games):
    """Upsert per-player game statistics into the SQLite player_games_hist table.

    Loads replay data for each new game, translates vehicle names, and inserts
    one row per player per session (skipping AI bot backfills). Sessions already
    present in the database are skipped for deduplication.

    Args:
        new_games: List of game dicts with ``sessionIdHex`` and ``endTime``.
    """
    if not new_games:
        return

    async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn:
        await init_players_db(conn)

        # Collect session IDs once
        session_ids = []
        end_times = {}
        for game in new_games:
            sid = game.get("sessionIdHex")
            if sid:
                session_ids.append(sid)
                end_times[sid] = int(game.get("endTime", 0) or 0)

        if not session_ids:
            return

        # Skip sessions already in DB
        placeholders = ",".join("?" for _ in session_ids)
        existing = await conn.execute_fetchall(
            f"SELECT DISTINCT session_id FROM player_games_hist WHERE session_id IN ({placeholders})",
            session_ids
        )
        existing_sessions = {row[0] for row in existing}
        sessions_to_process = [sid for sid in session_ids if sid not in existing_sessions]

        if not sessions_to_process:
            return

        translate = get_translator()

        all_rows = []
        await conn.execute("BEGIN;")

        for sid in sessions_to_process:
            endtime_raw = end_times.get(sid, 0)

            replay_path = replay_data_path(sid)

            try:
                raw = await asyncio.to_thread(replay_path.read_bytes)
                replay_data = json.loads(gzip.decompress(raw))
            except (FileNotFoundError, OSError, json.JSONDecodeError):
                continue

            winning_team = replay_data.get("winning_team_squadron")
            teams = replay_data.get("teams") or []

            for team in teams:
                # WT replay JSON occasionally leaks decorative tag glyphs (box-drawing
                # chars, brackets, "=" etc.) into the "squadron" field, producing
                # duplicate squadron_name variants for the same clan. Strip to plain
                # short-name alphanumerics; fall back to UNKNOWN if stripping empties.
                squadron_name = re.sub(r"[^A-Za-z0-9_-]", "", team.get("squadron", "UNKNOWN")) or "UNKNOWN"
                squadron_tagged = team.get("squadron_tagged", "UNKNOWN")
                victor_text = "Win" if (winning_team and squadron_name == winning_team) else "Loss"
                team_clan_id = _resolve_clan_id_for_pgh(squadron_name)

                players = team.get("players") or []
                for p in players:
                    uid = str(p.get("uid") or "").strip()
                    nick = str(p.get("nick") or "").strip()
                    if not (uid and nick and sid):
                        continue
                    # Skip AI bot backfills (e.g. "coop/Bot340") —
                    # these replace disconnected players but keep their UID,
                    # polluting the nick column for real players.
                    if nick.startswith("coop/"):
                        continue

                    veh_internal = p.get("vehicle") or "DISCONNECTED"
                    veh_display = p.get("vehicle_new")

                    if not veh_display:
                        if veh_internal != "DISCONNECTED":
                            veh_display = translate.get_translate(str(veh_internal))
                        else:
                            veh_display = "DISCONNECTED"

                    all_rows.append((
                        uid,
                        nick,
                        squadron_name,
                        squadron_tagged,
                        sid,
                        veh_display,
                        veh_internal,
                        as_int(p.get("ground_kills")),
                        as_int(p.get("air_kills")),
                        as_int(p.get("assists")),
                        as_int(p.get("captures")),
                        as_int(p.get("deaths")),
                        victor_text,
                        endtime_raw,
                        team_clan_id,
                    ))

        if not all_rows:
            await conn.execute("ROLLBACK;")
            return

        try:
            await conn.executemany(UPSERT_SQL, all_rows)
            await conn.commit()
        except Exception as e:
            await conn.execute("ROLLBACK;")
            logging.error(f"(STAT-MANAGER) DB upsert failed: {e}")


def _compact(obj: Any) -> str:
    """Serialize *obj* to a compact JSON string with no extra whitespace.

    Args:
        obj: JSON-serializable object.

    Returns:
        str: Minified JSON string.
    """
    return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))


def _find_winner_loser(replay_data: Dict[str, Any]) -> Optional[Tuple[Dict, Dict]]:
    """Extract the winner and loser team dicts from replay data.

    Args:
        replay_data: Parsed replay JSON containing ``teams``,
            ``winning_team_squadron``, and ``losing_team_squadron`` keys.

    Returns:
        A ``(winner_team, loser_team)`` tuple of team dicts, or ``None`` if
        fewer than two teams exist or either tag cannot be resolved.
    """
    teams: List[Dict] = replay_data.get("teams") or []
    if len(teams) < 2:
        return None

    winner_tag = replay_data.get("winning_team_squadron")
    loser_tag = replay_data.get("losing_team_squadron")

    # Map tags to team dicts
    tag_to_team = {(t or {}).get("squadron"): (t or {}) for t in teams}
    winner_team_dict = tag_to_team.get(winner_tag)
    loser_team_dict = tag_to_team.get(loser_tag)

    if winner_team_dict and loser_team_dict:
        return winner_team_dict, loser_team_dict


async def process_match_summaries(new_games: List[Dict[str, Any]]) -> None:
    """Create or update match_summary records for processed games.

    Stores session_id, map_name, endtime_unix, winning_sq, losing_sq,
    winning_team_json, and losing_team_json. Runs schema migration for
    any missing columns on first call.

    Args:
        new_games: List of replay dicts containing match details.
    """
    async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn:
        # Fresh DBs
        await conn.execute("""
        CREATE TABLE IF NOT EXISTS match_summary (
            session_id TEXT PRIMARY KEY,
            map_name     TEXT,
            endtime_unix INTEGER,
            winning_sq   TEXT,
            losing_sq    TEXT,
            winning_team_json TEXT,
            losing_team_json  TEXT
        )
        """)
        # Migrate (add any missing columns)
        cols = {r[1] for r in await conn.execute_fetchall("PRAGMA table_info(match_summary)")}
        wanted = {
            "session_id", "map_name", "endtime_unix", "winning_sq", "losing_sq",
            "winning_team_json", "losing_team_json", "game_type", "mission_mode"
        }
        for col in (wanted - cols):
            await conn.execute(f"ALTER TABLE match_summary ADD COLUMN {col} TEXT")
        if "received_unix" not in cols:
            await conn.execute("ALTER TABLE match_summary ADD COLUMN received_unix INTEGER")
        if "winning_clan_id" not in cols:
            await conn.execute("ALTER TABLE match_summary ADD COLUMN winning_clan_id INTEGER")
        if "losing_clan_id" not in cols:
            await conn.execute("ALTER TABLE match_summary ADD COLUMN losing_clan_id INTEGER")
        await conn.execute(
            "CREATE INDEX IF NOT EXISTS idx_ms_winning_clanid ON match_summary(winning_clan_id)"
        )
        await conn.execute(
            "CREATE INDEX IF NOT EXISTS idx_ms_losing_clanid ON match_summary(losing_clan_id)"
        )
        await conn.commit()

        # One-time backfill: older rows embed the gamemode in map_name as a
        # "[Conquest #1] <map>" prefix (sometimes with a leading space). Split
        # that into mission_mode and a clean map_name. Idempotent — only touches
        # rows where mission_mode is unset and the prefix is still present.
        await conn.execute("""
            UPDATE match_summary
            SET mission_mode = TRIM(SUBSTR(TRIM(map_name), 2, INSTR(TRIM(map_name), ']') - 2)),
                map_name     = LTRIM(SUBSTR(TRIM(map_name), INSTR(TRIM(map_name), ']') + 1))
            WHERE TRIM(map_name) LIKE '[%]%'
              AND (mission_mode IS NULL OR mission_mode = '')
        """)
        await conn.commit()

        rows: List[Tuple[str, str, int, str, str, bytes, bytes, str, str, Optional[int], Optional[int], Optional[int]]] = []

        for game in new_games:
            session_id: str = game.get("sessionIdHex") or ""

            if not session_id:
                continue

            received_raw = game.get("receivedTime")
            received_unix: Optional[int] = int(received_raw) if received_raw else None

            # Load replay JSON
            replay_path = replay_data_path(session_id)
            try:
                raw = await asyncio.to_thread(replay_path.read_bytes)
                replay_data = json.loads(gzip.decompress(raw))
            except (FileNotFoundError, OSError, json.JSONDecodeError):
                continue

            # Translate vehicle names & sanitize
            translate = LangTableReader("English")

            for team in replay_data.get("teams", []):
                for p in team.get("players", []):
                    veh = p.get("vehicle")
                    if veh:
                        p["vehicle_new"] = translate.get_translate(f"{veh}")
                    else:
                        p["vehicle"] = "DISCONNECTED"
                        p["vehicle_new"] = "DISCONNECTED"

            # Map fields
            map_name: str = str(game.get("missionName", "") or "")
            endtime_unix: int = int(game.get("endTime", 0) or 0)

            # Winner / loser
            wl = _find_winner_loser(replay_data)
            if not wl:
                continue
            winner_team, loser_team = wl

            winning_sq = str(winner_team.get("squadron") or "")
            losing_sq = str(loser_team.get("squadron") or "")
            winning_clan_id = _resolve_clan_id_for_pgh(winning_sq)
            losing_clan_id = _resolve_clan_id_for_pgh(losing_sq)

            rows.append((
                session_id,
                str(map_name),
                int(endtime_unix),
                winning_sq,
                losing_sq,
                compress_json(winner_team, ensure_ascii=False, separators=(",", ":")),
                compress_json(loser_team, ensure_ascii=False, separators=(",", ":")),
                str(replay_data.get("type", "")),
                str(replay_data.get("mode", "")),
                received_unix,
                winning_clan_id,
                losing_clan_id,
            ))

        if not rows:
            return

        # Upsert (one row per session)
        try:
            await conn.executemany("""
                INSERT INTO match_summary
                  (session_id, map_name, endtime_unix, winning_sq, losing_sq,
                   winning_team_json, losing_team_json, game_type, mission_mode,
                   received_unix, winning_clan_id, losing_clan_id)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(session_id) DO UPDATE SET
                  map_name          = excluded.map_name,
                  endtime_unix      = excluded.endtime_unix,
                  winning_sq        = excluded.winning_sq,
                  losing_sq         = excluded.losing_sq,
                  winning_team_json = excluded.winning_team_json,
                  losing_team_json  = excluded.losing_team_json,
                  game_type         = excluded.game_type,
                  mission_mode      = excluded.mission_mode,
                  received_unix     = COALESCE(excluded.received_unix, match_summary.received_unix),
                  winning_clan_id   = COALESCE(excluded.winning_clan_id, match_summary.winning_clan_id),
                  losing_clan_id    = COALESCE(excluded.losing_clan_id, match_summary.losing_clan_id)
            """, rows)
            await conn.commit()
        except Exception as e:
            logging.error(f"(MATCH-SUMMARY) DB upsert failed: {e}")
