namespace GeoSus.Server;

using System.Collections.Concurrent;
using System.Net;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;

/// <summary>
/// Admin Panel - Webová administrace serveru
/// 
/// Funkce:
/// - Přehled stavu serveru
/// - Real-time spectate mode pro lobby
/// - Konfigurace serveru za běhu
/// - Informace o hráčích
/// - WebSocket pro live updates
/// </summary>
public class AdminPanel
{
    private readonly ILogger<AdminPanel> _logger;
    private readonly ServerConfig _config;
    private readonly LobbyManager _lobbyManager;
    private readonly StatsDb _statsDb;
    
    // Bezpečnost
    private readonly string _adminPasswordHash;
    private readonly ConcurrentDictionary<string, AdminSession> _sessions = new();
    private readonly TimeSpan _sessionTimeout = TimeSpan.FromHours(8);
    
    // WebSocket connections pro spectate
    private readonly ConcurrentDictionary<string, SpectatorConnection> _spectators = new();
    
    public AdminPanel(
        ILogger<AdminPanel> logger,
        ServerConfig config,
        LobbyManager lobbyManager,
        StatsDb statsDb)
    {
        _logger = logger;
        _config = config;
        _lobbyManager = lobbyManager;
        _statsDb = statsDb;
        
        // Hash admin hesla (z env nebo default)
        var adminPassword = Environment.GetEnvironmentVariable("GEOSUS_ADMIN_PASSWORD") ?? "admin123";
        _adminPasswordHash = HashPassword(adminPassword);
        
        _logger.LogInformation("Admin Panel inicializován");
    }
    
    #region ═══════════════════════════════════════════════════════════════════
    //                         AUTENTIZACE
    // ════════════════════════════════════════════════════════════════════════
    #endregion
    
    private static string HashPassword(string password)
    {
        using var sha256 = SHA256.Create();
        var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password + "GeoSus_Salt_2026"));
        return Convert.ToBase64String(bytes);
    }
    
    private string? ValidateSession(HttpListenerRequest request)
    {
        var cookie = request.Cookies["geosus_admin_session"];
        if (cookie == null) return null;
        
        if (_sessions.TryGetValue(cookie.Value, out var session))
        {
            if (DateTime.UtcNow - session.LastActivity < _sessionTimeout)
            {
                session.LastActivity = DateTime.UtcNow;
                return cookie.Value;
            }
            _sessions.TryRemove(cookie.Value, out _);
        }
        return null;
    }
    
    private string CreateSession()
    {
        var sessionId = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
        _sessions[sessionId] = new AdminSession { LastActivity = DateTime.UtcNow };
        return sessionId;
    }
    
    #region ═══════════════════════════════════════════════════════════════════
    //                         HTTP HANDLING
    // ════════════════════════════════════════════════════════════════════════
    #endregion
    
    public async Task HandleRequestAsync(HttpListenerContext context)
    {
        var request = context.Request;
        var response = context.Response;
        var path = request.Url?.AbsolutePath ?? "/";
        
        try
        {
            // Login endpoint - vždy přístupný
            if (path == "/admin/login" && request.HttpMethod == "POST")
            {
                await HandleLoginAsync(request, response);
                return;
            }
            
            // Statické soubory pro login stránku - bez autentizace
            if (path == "/admin" || path == "/admin/")
            {
                await ServeStaticFileAsync(response, "admin.html", "text/html");
                return;
            }
            
            // CSS a JS musí být dostupné bez autentizace (pro login stránku)
            if (path == "/admin/app.js")
            {
                await ServeStaticFileAsync(response, "admin.js", "application/javascript");
                return;
            }
            
            if (path == "/admin/style.css")
            {
                await ServeStaticFileAsync(response, "admin.css", "text/css");
                return;
            }
            
            // Všechno ostatní vyžaduje autentizaci
            var sessionId = ValidateSession(request);
            if (sessionId == null)
            {
                response.StatusCode = 401;
                await WriteJsonAsync(response, new { error = "Unauthorized", redirect = "/admin" });
                return;
            }
            
            // WebSocket pro spectate
            if (path.StartsWith("/admin/ws/spectate/") && request.IsWebSocketRequest)
            {
                var lobbyId = path["/admin/ws/spectate/".Length..];
                await HandleSpectateWebSocketAsync(context, lobbyId, sessionId);
                return;
            }
            
            // WebSocket pro server stats
            if (path == "/admin/ws/stats" && request.IsWebSocketRequest)
            {
                await HandleStatsWebSocketAsync(context, sessionId);
                return;
            }

            // P13d: archive endpoints with archive id in the URL path
            // Layout: /admin/api/archive/<id>/events  and  /admin/api/archive/<id>/positions
            if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/events"))
            {
                var archiveId = path["/admin/api/archive/".Length..^"/events".Length];
                await HandleArchiveEventsAsync(archiveId, response);
                return;
            }
            if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/positions"))
            {
                var archiveId = path["/admin/api/archive/".Length..^"/positions".Length];
                await HandleArchivePositionsAsync(archiveId, response);
                return;
            }

            // REST API endpointy
            switch (path)
            {
                case "/admin/api/status":
                    await HandleStatusAsync(response);
                    break;
                    
                case "/admin/api/lobbies":
                    await HandleLobbiesAsync(response);
                    break;
                    
                case "/admin/api/config":
                    if (request.HttpMethod == "GET")
                        await HandleGetConfigAsync(response);
                    else if (request.HttpMethod == "POST")
                        await HandleSetConfigAsync(request, response);
                    break;
                    
                case "/admin/api/players":
                    await HandlePlayersAsync(request, response);
                    break;
                    
                case "/admin/api/kick":
                    await HandleKickAsync(request, response);
                    break;
                    
                case "/admin/api/broadcast":
                    await HandleBroadcastAsync(request, response);
                    break;

                // ── P12: lobby manipulation endpoints ────────────────────
                case "/admin/api/lobby/end":
                    await HandleLobbyForceEndAsync(request, response);
                    break;

                case "/admin/api/lobby/phase":
                    await HandleLobbySetPhaseAsync(request, response);
                    break;

                case "/admin/api/lobby/settings":
                    await HandleLobbyUpdateSettingsAsync(request, response);
                    break;

                // ── P13d: archive listing endpoint ───────────────────────
                case "/admin/api/archive":
                    await HandleListArchivesAsync(response);
                    break;

                case "/admin/api/logout":
                    _sessions.TryRemove(sessionId, out _);
                    response.SetCookie(new Cookie("geosus_admin_session", "", "/admin") { Expired = true });
                    await WriteJsonAsync(response, new { success = true });
                    break;
                    
                default:
                    response.StatusCode = 404;
                    await WriteJsonAsync(response, new { error = "Not found" });
                    break;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Admin panel error: {Path}", path);
            
            // Don't try to write response for WebSocket requests (already submitted)
            if (!request.IsWebSocketRequest)
            {
                try
                {
                    response.StatusCode = 500;
                    await WriteJsonAsync(response, new { error = ex.Message });
                }
                catch { /* Response may already be sent */ }
            }
        }
    }
    
    #region ═══════════════════════════════════════════════════════════════════
    //                         LOGIN
    // ════════════════════════════════════════════════════════════════════════
    #endregion
    
    private async Task HandleLoginAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        using var reader = new StreamReader(request.InputStream);
        var body = await reader.ReadToEndAsync();
        var data = JsonSerializer.Deserialize<JsonElement>(body);
        
        var password = data.GetProperty("password").GetString() ?? "";
        var hash = HashPassword(password);
        
        if (hash == _adminPasswordHash)
        {
            var sessionId = CreateSession();
            response.SetCookie(new Cookie("geosus_admin_session", sessionId, "/admin")
            {
                HttpOnly = true,
                Expires = DateTime.Now.Add(_sessionTimeout)
            });
            
            _logger.LogInformation("Admin login successful from {IP}", request.RemoteEndPoint);
            await WriteJsonAsync(response, new { success = true });
        }
        else
        {
            _logger.LogWarning("Admin login failed from {IP}", request.RemoteEndPoint);
            response.StatusCode = 401;
            await WriteJsonAsync(response, new { error = "Invalid password" });
        }
    }
    
    #region ═══════════════════════════════════════════════════════════════════
    //                         API ENDPOINTS
    // ════════════════════════════════════════════════════════════════════════
    #endregion
    
    private async Task HandleStatusAsync(HttpListenerResponse response)
    {
        var uptime = DateTime.UtcNow - Program.StartTime;
        var status = new
        {
            status = "online",
            version = "1.0.0",
            uptime = new
            {
                days = uptime.Days,
                hours = uptime.Hours,
                minutes = uptime.Minutes,
                seconds = uptime.Seconds,
                totalSeconds = (long)uptime.TotalSeconds
            },
            lobbies = new
            {
                active = _lobbyManager.LobbyCount,
                players = _lobbyManager.TotalPlayerCount
            },
            memory = new
            {
                usedMb = GC.GetTotalMemory(false) / 1024 / 1024,
                gcCollections = new
                {
                    gen0 = GC.CollectionCount(0),
                    gen1 = GC.CollectionCount(1),
                    gen2 = GC.CollectionCount(2)
                }
            },
            sessions = _sessions.Count,
            spectators = _spectators.Count
        };
        
        await WriteJsonAsync(response, status);
    }
    
    private async Task HandleLobbiesAsync(HttpListenerResponse response)
    {
        var lobbies = _lobbyManager.GetAllLobbies().Select(l => new
        {
            id = l.LobbyId,
            joinCode = l.JoinCode,
            phase = l.Phase.ToString(),
            players = l.GetPlayers().Select(p => new
            {
                id = p.PlayerId,
                name = p.DisplayName,
                state = p.State.ToString(),
                role = p.Role.ToString(),
                position = new { lat = p.Position.Lat, lng = p.Position.Lon },
                isHost = p.IsHost
            }),
            playerCount = l.PlayerCount,
            settings = new
            {
                impostorCount = l.Settings.ImpostorCount,
                taskCount = l.Settings.TaskCount,
                center = new { lat = l.Settings.PlayAreaCenter.Lat, lng = l.Settings.PlayAreaCenter.Lon },
                radius = l.Settings.PlayAreaRadius
            },
            createdAt = l.CreatedAt,
            activeSabotage = l.ActiveSabotage?.Type.ToString()
        });
        
        await WriteJsonAsync(response, new { lobbies });
    }
    
    /// <summary>
    /// P13b: full ServerConfig round-trip. The previous hand-rolled selective
    /// serialization only exposed ~10 fields; the admin panel now needs the
    /// entire appsettings.json shape (~50 fields). We serialize through the
    /// same JsonOptions the file loader uses so camelCase / enum-as-string
    /// stays consistent. The StatsApiKey is masked in the GET response so
    /// the secret doesn't leak into a browser dump.
    /// </summary>
    private async Task HandleGetConfigAsync(HttpListenerResponse response)
    {
        // Round-trip through JSON to get a clean copy + apply the server's
        // own naming policy / enum converters. Mask StatsApiKey on the way out.
        var json = JsonSerializer.Serialize(_config, JsonOptions.Indented);
        var node = JsonDocument.Parse(json).RootElement;

        // Re-emit with the api-key masked.
        using var ms = new MemoryStream();
        using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true }))
        {
            writer.WriteStartObject();
            foreach (var prop in node.EnumerateObject())
            {
                if (prop.NameEquals("statsApiKey"))
                {
                    var raw = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : null;
                    if (string.IsNullOrEmpty(raw)) writer.WriteNull("statsApiKey");
                    else writer.WriteString("statsApiKey", "********");
                    continue;
                }
                prop.WriteTo(writer);
            }
            writer.WriteEndObject();
        }

        response.ContentType = "application/json; charset=utf-8";
        var bytes = ms.ToArray();
        response.ContentLength64 = bytes.Length;
        await response.OutputStream.WriteAsync(bytes);
    }

    /// <summary>
    /// P13b: accept arbitrary subset of ServerConfig fields. Only updates
    /// fields the request actually carries; ignores unknown fields. Persists
    /// the file at appsettings.json on every successful update so changes
    /// survive restart. Apply rule: changes affect ONLY new lobbies created
    /// after this point - existing lobbies keep their snapshot of the values
    /// taken at their creation time. (See LobbySettings.FromDefaults.)
    /// </summary>
    private async Task HandleSetConfigAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        using var reader = new StreamReader(request.InputStream);
        var body = await reader.ReadToEndAsync();
        var data = JsonSerializer.Deserialize<JsonElement>(body);

        // Reflection-based update: walk every public settable property on
        // ServerConfig, look for a matching camelCased field in the JSON,
        // try to coerce. Skip the masked key sentinel so the user can leave
        // ******** in the form without nuking their real key.
        var props = typeof(ServerConfig).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
        int applied = 0;
        foreach (var p in props)
        {
            if (!p.CanWrite) continue;
            var camel = char.ToLowerInvariant(p.Name[0]) + p.Name[1..];
            if (!data.TryGetProperty(camel, out var el)) continue;

            try
            {
                if (p.Name == nameof(ServerConfig.StatsApiKey) &&
                    el.ValueKind == JsonValueKind.String && el.GetString() == "********")
                {
                    continue; // masked sentinel - leave existing
                }

                object? val = (Type.GetTypeCode(p.PropertyType)) switch
                {
                    TypeCode.Int32 => el.ValueKind == JsonValueKind.Number ? el.GetInt32() : int.TryParse(el.GetString(), out var i) ? i : (object?)null,
                    TypeCode.Int64 => el.ValueKind == JsonValueKind.Number ? el.GetInt64() : long.TryParse(el.GetString(), out var l) ? l : (object?)null,
                    TypeCode.Double => el.ValueKind == JsonValueKind.Number ? el.GetDouble() : double.TryParse(el.GetString(), out var d) ? d : (object?)null,
                    TypeCode.Boolean => el.ValueKind == JsonValueKind.True ? true : el.ValueKind == JsonValueKind.False ? false : (object?)null,
                    TypeCode.String => el.ValueKind == JsonValueKind.String ? el.GetString() : el.ValueKind == JsonValueKind.Null ? null : (object?)null,
                    _ => p.PropertyType.IsEnum && el.ValueKind == JsonValueKind.String && Enum.TryParse(p.PropertyType, el.GetString(), true, out var ev) ? ev : null
                };
                if (val != null || p.PropertyType == typeof(string))
                {
                    p.SetValue(_config, val);
                    applied++;
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Admin SetConfig: failed to set {Property}", p.Name);
            }
        }

        // Persist to disk so the change survives restart.
        try
        {
            _config.Save("appsettings.json");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to persist admin config update to appsettings.json");
        }

        _logger.LogInformation("Config updated via admin panel: {Count} field(s)", applied);

        await WriteJsonAsync(response, new { success = true, fieldsApplied = applied });
    }
    
    private async Task HandlePlayersAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        var query = request.QueryString;
        var search = query["search"] ?? "";
        
        var allPlayers = _lobbyManager.GetAllLobbies()
            .SelectMany(l => l.GetPlayers().Select(p => new
            {
                playerId = p.PlayerId,
                displayName = p.DisplayName,
                lobbyId = l.LobbyId,
                joinCode = l.JoinCode,
                state = p.State.ToString(),
                role = p.Role.ToString(),
                position = new { lat = p.Position.Lat, lng = p.Position.Lon },
                isHost = p.IsHost,
                cheatScore = p.CheatScore
            }))
            .Where(p => string.IsNullOrEmpty(search) || 
                        p.displayName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
                        p.playerId.Contains(search, StringComparison.OrdinalIgnoreCase))
            .ToList();
        
        await WriteJsonAsync(response, new { players = allPlayers, total = allPlayers.Count });
    }
    
    private async Task HandleKickAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        using var reader = new StreamReader(request.InputStream);
        var body = await reader.ReadToEndAsync();
        var data = JsonSerializer.Deserialize<JsonElement>(body);
        
        var playerId = data.GetProperty("playerId").GetString();
        var reason = data.TryGetProperty("reason", out var r) ? r.GetString() : "Kicked by admin";
        
        // Najdi hráče a kickni ho
        foreach (var lobby in _lobbyManager.GetAllLobbies())
        {
            if (lobby.TryKickPlayer(playerId!, reason!))
            {
                _logger.LogInformation("Player {PlayerId} kicked by admin: {Reason}", playerId, reason);
                await WriteJsonAsync(response, new { success = true });
                return;
            }
        }
        
        response.StatusCode = 404;
        await WriteJsonAsync(response, new { error = "Player not found" });
    }
    
    private async Task HandleBroadcastAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        using var reader = new StreamReader(request.InputStream);
        var body = await reader.ReadToEndAsync();
        var data = JsonSerializer.Deserialize<JsonElement>(body);

        var message = data.GetProperty("message").GetString();
        var lobbyId = data.TryGetProperty("lobbyId", out var l) ? l.GetString() : null;

        if (string.IsNullOrEmpty(lobbyId))
        {
            // Broadcast všem
            foreach (var lobby in _lobbyManager.GetAllLobbies())
            {
                lobby.BroadcastSystemMessage(message!);
            }
        }
        else
        {
            // Broadcast do konkrétní lobby
            var lobby = _lobbyManager.GetLobby(lobbyId);
            lobby?.BroadcastSystemMessage(message!);
        }

        _logger.LogInformation("Admin broadcast: {Message}", message);
        await WriteJsonAsync(response, new { success = true });
    }

    // ── P12: lobby manipulation endpoint handlers ────────────────────────────

    /// <summary>
    /// Force-end a game in a specific lobby. POST { "lobbyId": "...", "reason": "optional" }.
    /// Returns { success: true } on application, 404 if lobby missing, or
    /// 409 if the lobby was already in Lobby/Ended phase (nothing to end).
    /// </summary>
    private async Task HandleLobbyForceEndAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        using var reader = new StreamReader(request.InputStream);
        var body = await reader.ReadToEndAsync();
        var data = JsonSerializer.Deserialize<JsonElement>(body);

        var lobbyId = data.GetProperty("lobbyId").GetString();
        var reason = data.TryGetProperty("reason", out var r) ? (r.GetString() ?? "ended by admin") : "ended by admin";

        var lobby = _lobbyManager.GetLobby(lobbyId!);
        if (lobby == null)
        {
            response.StatusCode = 404;
            await WriteJsonAsync(response, new { error = "Lobby not found" });
            return;
        }

        var ok = await lobby.TryForceEndGameAsync(reason);
        if (!ok)
        {
            response.StatusCode = 409;
            await WriteJsonAsync(response, new { error = "Cannot force-end - lobby not in an active game phase" });
            return;
        }

        await WriteJsonAsync(response, new { success = true });
    }

    /// <summary>
    /// Force a lobby into a specific phase. POST { "lobbyId": "...", "phase": "Playing|Meeting|Ended|..." }.
    /// Voting is rejected (it's not a real wire phase - lives inside Meeting).
    /// </summary>
    private async Task HandleLobbySetPhaseAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        using var reader = new StreamReader(request.InputStream);
        var body = await reader.ReadToEndAsync();
        var data = JsonSerializer.Deserialize<JsonElement>(body);

        var lobbyId = data.GetProperty("lobbyId").GetString();
        var phase = data.GetProperty("phase").GetString();

        var lobby = _lobbyManager.GetLobby(lobbyId!);
        if (lobby == null)
        {
            response.StatusCode = 404;
            await WriteJsonAsync(response, new { error = "Lobby not found" });
            return;
        }

        var ok = await lobby.TryAdminSetPhaseAsync(phase!);
        if (!ok)
        {
            response.StatusCode = 400;
            await WriteJsonAsync(response, new { error = "Invalid phase (rejected: 'Voting' is not a wire phase)" });
            return;
        }

        await WriteJsonAsync(response, new { success = true });
    }

    /// <summary>
    /// Update lobby settings. POST { "lobbyId": "...", "radius": ..., "impostorCount": ..., "taskCount": ..., "tiePolicy": "..." }.
    /// All settings fields optional - only those present in the body are
    /// applied. Lobby must be in Lobby phase; mid-game settings changes are
    /// rejected to keep gameplay consistent.
    /// </summary>
    private async Task HandleLobbyUpdateSettingsAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        using var reader = new StreamReader(request.InputStream);
        var body = await reader.ReadToEndAsync();
        var data = JsonSerializer.Deserialize<JsonElement>(body);

        var lobbyId = data.GetProperty("lobbyId").GetString();

        double? radius = data.TryGetProperty("radius", out var rd) && rd.ValueKind == JsonValueKind.Number ? rd.GetDouble() : (double?)null;
        int? impostors = data.TryGetProperty("impostorCount", out var ic) && ic.ValueKind == JsonValueKind.Number ? ic.GetInt32() : (int?)null;
        int? tasks = data.TryGetProperty("taskCount", out var tc) && tc.ValueKind == JsonValueKind.Number ? tc.GetInt32() : (int?)null;
        string? tiePolicy = data.TryGetProperty("tiePolicy", out var tp) ? tp.GetString() : null;

        var lobby = _lobbyManager.GetLobby(lobbyId!);
        if (lobby == null)
        {
            response.StatusCode = 404;
            await WriteJsonAsync(response, new { error = "Lobby not found" });
            return;
        }

        var ok = await lobby.TryUpdateLobbySettingsAsync(radius, impostors, tasks, tiePolicy);
        if (!ok)
        {
            response.StatusCode = 409;
            await WriteJsonAsync(response, new { error = "Cannot update settings - lobby is past the Lobby phase" });
            return;
        }

        await WriteJsonAsync(response, new { success = true });
    }

    #region ═══════════════════════════════════════════════════════════════════
    //                         P13d: ARCHIVE
    // ════════════════════════════════════════════════════════════════════════
    #endregion

    /// <summary>
    /// P13d: list every archived lobby on disk. Persistence.ArchiveLobby
    /// drops them at <DataPath>/archive/<lobbyId>_<yyyyMMddHHmmss>/. We parse
    /// the directory name back into (lobbyId, timestamp) for the UI, count
    /// the WAL events, and report on-disk size so the operator can spot
    /// abnormally-large or empty runs at a glance.
    /// </summary>
    private async Task HandleListArchivesAsync(HttpListenerResponse response)
    {
        var archiveDir = Path.Combine(_config.DataPath, "archive");
        if (!Directory.Exists(archiveDir))
        {
            await WriteJsonAsync(response, new { archives = Array.Empty<object>() });
            return;
        }

        var archives = Directory.GetDirectories(archiveDir)
            .Select(d => new DirectoryInfo(d))
            .OrderByDescending(d => d.CreationTimeUtc)
            .Select(d =>
            {
                var name = d.Name;
                var lastUnderscore = name.LastIndexOf('_');
                string lobbyId = name;
                string timestampRaw = "";
                if (lastUnderscore > 0)
                {
                    lobbyId = name[..lastUnderscore];
                    timestampRaw = name[(lastUnderscore + 1)..];
                }

                string isoTimestamp = "";
                if (DateTime.TryParseExact(timestampRaw, "yyyyMMddHHmmss",
                    System.Globalization.CultureInfo.InvariantCulture,
                    System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
                    out var parsedTs))
                {
                    isoTimestamp = parsedTs.ToString("o");
                }
                else
                {
                    // Fallback: directory creation time.
                    isoTimestamp = d.CreationTimeUtc.ToString("o");
                }

                int eventCount = 0;
                long sizeBytes = 0;
                try
                {
                    foreach (var w in Directory.GetFiles(d.FullName, "wal_*.ndjson"))
                    {
                        var fi = new FileInfo(w);
                        sizeBytes += fi.Length;
                        eventCount += File.ReadLines(w).Count(l => !string.IsNullOrWhiteSpace(l));
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "Failed to stat archive {Dir}", d.FullName);
                }

                return new
                {
                    id = name,
                    lobbyId,
                    timestamp = isoTimestamp,
                    eventCount,
                    sizeBytes
                };
            })
            .ToList();

        await WriteJsonAsync(response, new { archives });
    }

    /// <summary>
    /// P13d: stream a single archive's WAL events back to the admin panel as
    /// a JSON array. The on-disk format is NDJSON (one already-serialized
    /// GameEvent per line); we glue the lines with commas inside [] rather
    /// than re-parsing each one - the admin panel doesn't need the parsed
    /// tree on the server side, and zero-copy keeps memory usage bounded
    /// even for hour-long lobbies.
    /// </summary>
    private async Task HandleArchiveEventsAsync(string archiveId, HttpListenerResponse response)
    {
        if (!IsSafeArchiveId(archiveId))
        {
            response.StatusCode = 400;
            await WriteJsonAsync(response, new { error = "Invalid archive id" });
            return;
        }

        var archivePath = Path.Combine(_config.DataPath, "archive", archiveId);
        if (!Directory.Exists(archivePath))
        {
            response.StatusCode = 404;
            await WriteJsonAsync(response, new { error = "Archive not found" });
            return;
        }

        var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson")
            .OrderBy(f => f)
            .ToList();

        // Buffer the whole array in memory so we can set Content-Length and
        // avoid chunked transfer headaches. Typical lobby archives are well
        // under 10MB; if we ever need to stream, swap in chunked-encoding here.
        var sb = new StringBuilder();
        sb.Append('[');
        bool first = true;
        foreach (var w in walFiles)
        {
            string[] lines;
            try
            {
                lines = await File.ReadAllLinesAsync(w);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to read archive WAL {Path}", w);
                continue;
            }
            foreach (var line in lines)
            {
                if (string.IsNullOrWhiteSpace(line)) continue;
                if (!first) sb.Append(',');
                sb.Append(line);
                first = false;
            }
        }
        sb.Append(']');

        response.ContentType = "application/json; charset=utf-8";
        var bytes = Encoding.UTF8.GetBytes(sb.ToString());
        response.ContentLength64 = bytes.Length;
        await response.OutputStream.WriteAsync(bytes);
    }

    /// <summary>
    /// P13d: extract per-player polylines from an archive's WAL. We don't
    /// persist UpdatePosition (high-rate client telemetry), so we reconstruct
    /// movement traces from the events that DO carry coordinates: kill/body/
    /// task-start/meeting-call/etc. Each event with a `location` or `position`
    /// payload is attributed to the actor (preferred) or the victim/clientUuid
    /// inside the payload, then stitched into a per-player time-ordered list
    /// the frontend renders as a Leaflet polyline.
    /// </summary>
    private async Task HandleArchivePositionsAsync(string archiveId, HttpListenerResponse response)
    {
        if (!IsSafeArchiveId(archiveId))
        {
            response.StatusCode = 400;
            await WriteJsonAsync(response, new { error = "Invalid archive id" });
            return;
        }

        var archivePath = Path.Combine(_config.DataPath, "archive", archiveId);
        if (!Directory.Exists(archivePath))
        {
            response.StatusCode = 404;
            await WriteJsonAsync(response, new { error = "Archive not found" });
            return;
        }

        var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson")
            .OrderBy(f => f)
            .ToList();

        var polylines = new Dictionary<string, List<object>>();
        var playerNames = new Dictionary<string, string>();

        foreach (var w in walFiles)
        {
            string[] lines;
            try
            {
                lines = await File.ReadAllLinesAsync(w);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to read archive WAL {Path}", w);
                continue;
            }

            foreach (var line in lines)
            {
                if (string.IsNullOrWhiteSpace(line)) continue;

                JsonDocument? doc = null;
                try { doc = JsonDocument.Parse(line); }
                catch { continue; }
                using (doc)
                {
                    var root = doc.RootElement;
                    string eventType = root.TryGetProperty("eventType", out var et) ? (et.GetString() ?? "") : "";
                    string actor = root.TryGetProperty("actor", out var a) ? (a.GetString() ?? "") : "";
                    string ts = root.TryGetProperty("timestamp", out var t) ? (t.GetString() ?? "") : "";

                    if (!root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object)
                        continue;

                    // Capture display names from PlayerJoined for legend labels.
                    if (eventType == "PlayerJoined")
                    {
                        string cid = payload.TryGetProperty("clientUuid", out var c) ? (c.GetString() ?? "") : "";
                        string dn = payload.TryGetProperty("displayName", out var d) ? (d.GetString() ?? "") : "";
                        if (!string.IsNullOrEmpty(cid) && !string.IsNullOrEmpty(dn))
                            playerNames[cid] = dn;
                        continue;
                    }

                    // Find a (lat,lon) pair in either `location` or `position`.
                    double? lat = null, lon = null;
                    if (payload.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object)
                    {
                        if (loc.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble();
                        if (loc.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble();
                    }
                    else if (payload.TryGetProperty("position", out var pos) && pos.ValueKind == JsonValueKind.Object)
                    {
                        if (pos.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble();
                        if (pos.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble();
                    }

                    if (!lat.HasValue || !lon.HasValue) continue;

                    // Attribute the position to a specific player. PlayerKilled
                    // gives us the victim's last position (more useful than the
                    // killer's for trace reconstruction); everything else falls
                    // through to actor / payload.clientUuid.
                    string? pid = null;
                    if (eventType == "PlayerKilled")
                        pid = payload.TryGetProperty("victimId", out var v) ? v.GetString() : null;
                    else if (!string.IsNullOrEmpty(actor))
                        pid = actor;
                    else if (payload.TryGetProperty("clientUuid", out var c2))
                        pid = c2.GetString();

                    if (string.IsNullOrEmpty(pid)) continue;

                    if (!polylines.TryGetValue(pid, out var list))
                    {
                        list = new List<object>();
                        polylines[pid] = list;
                    }
                    list.Add(new { lat = lat.Value, lon = lon.Value, ts, evt = eventType });
                }
            }
        }

        var result = polylines.Select(kvp => new
        {
            playerId = kvp.Key,
            displayName = playerNames.TryGetValue(kvp.Key, out var n) ? n : kvp.Key,
            points = kvp.Value
        }).ToList();

        await WriteJsonAsync(response, new { polylines = result });
    }

    /// <summary>
    /// Reject any archive id containing a path-traversal token. We also bar
    /// raw separators because the on-disk layout is one flat directory per
    /// archive - no nesting expected.
    /// </summary>
    private static bool IsSafeArchiveId(string archiveId)
    {
        if (string.IsNullOrEmpty(archiveId)) return false;
        if (archiveId.Contains("..")) return false;
        if (archiveId.Contains('/') || archiveId.Contains('\\')) return false;
        return true;
    }

    #region ═══════════════════════════════════════════════════════════════════
    //                         WEBSOCKET - SPECTATE
    // ════════════════════════════════════════════════════════════════════════
    #endregion
    
    private async Task HandleSpectateWebSocketAsync(HttpListenerContext context, string lobbyId, string sessionId)
    {
        var wsContext = await context.AcceptWebSocketAsync(null);
        var ws = wsContext.WebSocket;
        var spectatorId = Guid.NewGuid().ToString("N")[..8];
        
        var lobby = _lobbyManager.GetLobby(lobbyId);
        if (lobby == null)
        {
            try
            {
                await ws.CloseAsync(WebSocketCloseStatus.InvalidPayloadData, "Lobby not found", CancellationToken.None);
            }
            catch (WebSocketException) { /* Client already disconnected */ }
            return;
        }
        
        var connection = new SpectatorConnection
        {
            WebSocket = ws,
            LobbyId = lobbyId,
            SessionId = sessionId
        };
        
        _spectators[spectatorId] = connection;
        _logger.LogInformation("Spectator {Id} connected to lobby {LobbyId}", spectatorId, lobbyId);
        
        try
        {
            // Pošli initial state - include the heavy mapData payload exactly
            // once per spectator session. The push loop below sends just the
            // dynamic fields (positions/votes/sabotage/etc.) at 5Hz.
            await SendLobbyStateAsync(ws, lobby, includeMapData: true);
            connection.MapDataSent = true;
            
            var buffer = new byte[4096];
            var updateInterval = TimeSpan.FromMilliseconds(200);
            var cts = new CancellationTokenSource();
            var receivedMessage = new TaskCompletionSource<(bool closed, string? message)>();
            
            // Spustíme receive task separátně
            var receiveTask = Task.Run(async () =>
            {
                try
                {
                    while (ws.State == WebSocketState.Open && !cts.Token.IsCancellationRequested)
                    {
                        var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
                        if (result.MessageType == WebSocketMessageType.Close)
                        {
                            return true; // closed
                        }
                        
                        if (result.Count > 0)
                        {
                            var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
                            await HandleSpectatorMessageAsync(ws, lobby!, message);
                        }
                    }
                }
                catch (OperationCanceledException) { }
                catch (WebSocketException) { }
                return ws.State != WebSocketState.Open;
            });
            
            // Hlavní smyčka posílá state
            while (ws.State == WebSocketState.Open && !receiveTask.IsCompleted)
            {
                lobby = _lobbyManager.GetLobby(lobbyId);
                if (lobby != null)
                {
                    // Subsequent ticks: skip mapData (heavy + immutable during
                    // a game). Spectator's frontend cached it from the initial
                    // push.
                    await SendLobbyStateAsync(ws, lobby, includeMapData: false);
                }
                else
                {
                    break;
                }
                
                try
                {
                    await Task.WhenAny(receiveTask, Task.Delay(updateInterval, cts.Token));
                }
                catch (OperationCanceledException) { break; }
            }
            
            cts.Cancel();
            
            // Gracefully close if still open
            if (ws.State == WebSocketState.Open)
            {
                try
                {
                    await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, lobby == null ? "Lobby closed" : "Closing", CancellationToken.None);
                }
                catch (WebSocketException) { /* Already closed by client */ }
            }
        }
        catch (WebSocketException)
        {
            // Client disconnected - this is normal
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Spectate WebSocket error");
        }
        finally
        {
            _spectators.TryRemove(spectatorId, out _);
            _logger.LogInformation("Spectator {Id} disconnected", spectatorId);
        }
    }
    
    /// <summary>
    /// Push the lobby state to a spectator's WebSocket. When `includeMapData`
    /// is false (the default for the periodic 5Hz push loop) the heavy
    /// Overpass payload is omitted - the frontend keeps the copy it received
    /// on the initial push. This was the dominant cost in the "admin panel
    /// slow to show spectating" complaint: a typical lobby's mapData is tens
    /// of KB and serializing it 5x per second per spectator added up fast.
    /// </summary>
    private async Task SendLobbyStateAsync(WebSocket ws, LobbyActor lobby, bool includeMapData = true)
    {
        var state = new
        {
            type = "state",
            lobby = new
            {
                id = lobby.LobbyId,
                joinCode = lobby.JoinCode,
                phase = lobby.Phase.ToString(),
                phaseEndTime = lobby.PhaseEndTime?.ToString("o"),

                players = lobby.GetPlayers().Select(p => new
                {
                    id = p.PlayerId,
                    name = p.DisplayName,
                    state = p.State.ToString(),
                    role = p.Role.ToString(),
                    lat = p.Position.Lat,
                    lng = p.Position.Lon,
                    isHost = p.IsHost,
                    cheatScore = p.CheatScore,
                    killCooldownEnd = p.KillCooldownEnd?.ToString("o"),
                    votedFor = p.VotedFor
                }),

                tasks = lobby.GetTasks().Select(t => new
                {
                    id = t.TaskId,
                    name = t.Name,
                    lat = t.Location.Lat,
                    lng = t.Location.Lon,
                    completedBy = t.CompletedBy.ToList()
                }),

                bodies = lobby.GetBodies().Select(b => new
                {
                    victimId = b.VictimId,
                    lat = b.Position.Lat,
                    lng = b.Position.Lon,
                    reportedAt = b.ReportedAt?.ToString("o")
                }),

                sabotage = lobby.ActiveSabotage != null ? new
                {
                    type = lobby.ActiveSabotage.Type.ToString(),
                    deadline = lobby.ActiveSabotage.Deadline?.ToString("o"),
                    repairStations = lobby.ActiveSabotage.RepairStations.Select(rs => new
                    {
                        id = rs.StationId,
                        lat = rs.Location.Lat,
                        lng = rs.Location.Lon,
                        isRepaired = rs.IsRepaired
                    })
                } : null,

                votes = lobby.Phase == GamePhase.Voting ? lobby.GetVotes() : null,

                settings = new
                {
                    center = new { lat = lobby.Settings.PlayAreaCenter.Lat, lng = lobby.Settings.PlayAreaCenter.Lon },
                    radius = lobby.Settings.PlayAreaRadius,
                    impostorCount = lobby.Settings.ImpostorCount,
                    taskCount = lobby.Settings.TaskCount
                },

                // mapData on first push only - frontend caches it client-side.
                mapData = includeMapData ? lobby.MapData : null
            },
            timestamp = DateTime.UtcNow.ToString("o")
        };

        var json = JsonSerializer.Serialize(state, JsonOptions.Default);
        var bytes = Encoding.UTF8.GetBytes(json);

        if (ws.State == WebSocketState.Open)
        {
            await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
        }
    }
    
    private async Task HandleSpectatorMessageAsync(WebSocket ws, LobbyActor lobby, string message)
    {
        try
        {
            var data = JsonSerializer.Deserialize<JsonElement>(message);
            var type = data.GetProperty("type").GetString();
            
            switch (type)
            {
                case "ping":
                    await SendJsonAsync(ws, new { type = "pong" });
                    break;
                    
                case "getPlayerHistory":
                    var playerId = data.GetProperty("playerId").GetString();
                    var stats = _statsDb.GetPlayerStats(playerId!);
                    await SendJsonAsync(ws, new { type = "playerHistory", stats });
                    break;
            }
        }
        catch { }
    }
    
    #region ═══════════════════════════════════════════════════════════════════
    //                         WEBSOCKET - SERVER STATS
    // ════════════════════════════════════════════════════════════════════════
    #endregion
    
    private async Task HandleStatsWebSocketAsync(HttpListenerContext context, string sessionId)
    {
        var wsContext = await context.AcceptWebSocketAsync(null);
        var ws = wsContext.WebSocket;
        
        try
        {
            var buffer = new byte[1024];
            var updateInterval = TimeSpan.FromSeconds(1);
            var cts = new CancellationTokenSource();
            
            // Spustíme receive task separátně - bude běžet celou dobu
            var receiveTask = Task.Run(async () =>
            {
                try
                {
                    while (ws.State == WebSocketState.Open && !cts.Token.IsCancellationRequested)
                    {
                        var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
                        if (result.MessageType == WebSocketMessageType.Close)
                        {
                            return;
                        }
                    }
                }
                catch (OperationCanceledException) { }
                catch (WebSocketException) { }
            });
            
            // Hlavní smyčka posílá stats
            while (ws.State == WebSocketState.Open && !receiveTask.IsCompleted)
            {
                var uptime = DateTime.UtcNow - Program.StartTime;
                var stats = new
                {
                    type = "stats",
                    uptime = (long)uptime.TotalSeconds,
                    lobbies = _lobbyManager.LobbyCount,
                    players = _lobbyManager.TotalPlayerCount,
                    memoryMb = GC.GetTotalMemory(false) / 1024 / 1024,
                    spectators = _spectators.Count,
                    timestamp = DateTime.UtcNow.ToString("o")
                };
                
                await SendJsonAsync(ws, stats);
                
                // Čekáme na interval nebo ukončení receive tasku
                var delayTask = Task.Delay(updateInterval, cts.Token);
                try
                {
                    await Task.WhenAny(receiveTask, delayTask);
                }
                catch (OperationCanceledException) { break; }
            }
            
            cts.Cancel();
            
            if (ws.State == WebSocketState.Open)
            {
                try
                {
                    await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
                }
                catch (WebSocketException) { /* Already closed */ }
            }
        }
        catch (WebSocketException) { }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Stats WebSocket error");
        }
    }
    
    #region ═══════════════════════════════════════════════════════════════════
    //                         HELPERS
    // ════════════════════════════════════════════════════════════════════════
    #endregion
    
    private static async Task WriteJsonAsync(HttpListenerResponse response, object data)
    {
        response.ContentType = "application/json";
        var json = JsonSerializer.Serialize(data, JsonOptions.Default);
        var bytes = Encoding.UTF8.GetBytes(json);
        response.ContentLength64 = bytes.Length;
        await response.OutputStream.WriteAsync(bytes);
    }
    
    private static async Task SendJsonAsync(WebSocket ws, object data)
    {
        var json = JsonSerializer.Serialize(data, JsonOptions.Default);
        var bytes = Encoding.UTF8.GetBytes(json);
        if (ws.State == WebSocketState.Open)
        {
            await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
        }
    }
    
    private async Task ServeStaticFileAsync(HttpListenerResponse response, string filename, string contentType)
    {
        var content = GetEmbeddedResource(filename);
        if (content == null)
        {
            response.StatusCode = 404;
            return;
        }
        
        response.ContentType = contentType + "; charset=utf-8";
        var bytes = Encoding.UTF8.GetBytes(content);
        response.ContentLength64 = bytes.Length;
        await response.OutputStream.WriteAsync(bytes);
    }
    
    private string? GetEmbeddedResource(string filename)
    {
        // Embedded resources z AdminResources třídy
        return filename switch
        {
            "admin.html" => AdminResources.Html,
            "admin.css" => AdminResources.Css,
            "admin.js" => AdminResources.JavaScript,
            _ => null
        };
    }
}

public class AdminSession
{
    public DateTime LastActivity { get; set; }
}

public class SpectatorConnection
{
    public required WebSocket WebSocket { get; set; }
    public required string LobbyId { get; set; }
    public required string SessionId { get; set; }

    /// <summary>
    /// P12: per-spectator flag to ensure we serialize the heavy `mapData`
    /// payload only once at session start. The previous push loop included
    /// it on every 200ms tick, which dominated CPU + bandwidth and made the
    /// admin panel feel laggy ("slow to show spectating") even though the
    /// position/vote/role updates were already arriving promptly.
    /// </summary>
    public bool MapDataSent { get; set; } = false;
}
