using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace AnsonBot.Core;

public class TrackerOptions
{
    public int PollSeconds { get; set; } = 60;

    /// <summary>
    /// Consecutive polls a player must be absent before their session is
    /// closed. Guards against a single dropped/partial ShowPlayers response
    /// registering as everyone leaving and rejoining.
    /// </summary>
    public int MissedPollsBeforeLeave { get; set; } = 2;
}

public record PlayerEvent(string PlayerId, string Name, TimeSpan Duration);

public class PlayerTrackerService : BackgroundService
{
    private readonly IRconService _rcon;
    private readonly IDbContextFactory<BotDbContext> _dbFactory;
    private readonly TrackerOptions _options;
    private readonly ILogger<PlayerTrackerService> _logger;

    // PlayerId -> consecutive polls missing.
    private readonly Dictionary<string, int> _missing = new();

    public event Func<PlayerEvent, Task>? PlayerJoined;
    public event Func<PlayerEvent, Task>? PlayerLeft;

    public PlayerTrackerService(
        IRconService rcon,
        IDbContextFactory<BotDbContext> dbFactory,
        IOptions<TrackerOptions> options,
        ILogger<PlayerTrackerService> logger)
    {
        _rcon = rcon;
        _dbFactory = dbFactory;
        _options = options.Value;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await RecoverOrphanedSessionsAsync(ct);

        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.PollSeconds));

        do
        {
            try
            {
                await PollAsync(ct);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                // Deliberately swallow: a failed poll (server restarting, tunnel
                // blipped) must not kill the loop or close everyone's session.
                _logger.LogWarning(ex, "Poll failed; leaving sessions untouched.");
            }
        }
        while (await timer.WaitForNextTickAsync(ct));
    }

    /// <summary>
    /// If the bot died without closing sessions, settle them at the last time
    /// we actually saw the player rather than leaving them open forever.
    /// </summary>
    private async Task RecoverOrphanedSessionsAsync(CancellationToken ct)
    {
        await using var db = await _dbFactory.CreateDbContextAsync(ct);
        await db.Database.MigrateAsync(ct);

        var open = await db.PlaySessions
            .Where(s => s.EndedUtc == null)
            .ToListAsync(ct);

        if (open.Count == 0) return;

        foreach (var s in open)
            s.EndedUtc = s.LastSeenUtc;

        await db.SaveChangesAsync(ct);
        _logger.LogInformation("Closed {Count} orphaned session(s) from a previous run.", open.Count);
    }

    private async Task PollAsync(CancellationToken ct)
    {
        var raw = await _rcon.SendAsync("ShowPlayers", ct);
        var online = ShowPlayersParser.Parse(raw);
        var now = DateTime.UtcNow;

        await using var db = await _dbFactory.CreateDbContextAsync(ct);

        var openSessions = await db.PlaySessions
            .Where(s => s.EndedUtc == null)
            .ToListAsync(ct);

        var openById = openSessions.ToDictionary(s => s.PlayerId);
        var onlineById = online
            .GroupBy(p => p.Id)
            .ToDictionary(g => g.Key, g => g.First());

        var joined = new List<PlayerEvent>();
        var left = new List<PlayerEvent>();

        // Joins, and heartbeat for anyone still connected.
        foreach (var (id, player) in onlineById)
        {
            _missing.Remove(id);

            if (openById.TryGetValue(id, out var existing))
            {
                existing.LastSeenUtc = now;
                existing.Name = player.Name; // keep up with renames
            }
            else
            {
                db.PlaySessions.Add(new PlaySession
                {
                    PlayerId = id,
                    Name = player.Name,
                    StartedUtc = now,
                    LastSeenUtc = now
                });

                joined.Add(new PlayerEvent(id, player.Name, TimeSpan.Zero));
            }
        }

        // Leaves, only after enough consecutive absences.
        foreach (var session in openSessions)
        {
            if (onlineById.ContainsKey(session.PlayerId)) continue;

            var misses = _missing.GetValueOrDefault(session.PlayerId) + 1;
            _missing[session.PlayerId] = misses;

            if (misses < _options.MissedPollsBeforeLeave) continue;

            // Credit them only up to when we last actually saw them.
            session.EndedUtc = session.LastSeenUtc;
            _missing.Remove(session.PlayerId);

            left.Add(new PlayerEvent(
                session.PlayerId, session.Name, session.EndedUtc.Value - session.StartedUtc));
        }

        await db.SaveChangesAsync(ct);

        foreach (var e in joined) await RaiseAsync(PlayerJoined, e);
        foreach (var e in left) await RaiseAsync(PlayerLeft, e);
    }

    private async Task RaiseAsync(Func<PlayerEvent, Task>? handler, PlayerEvent e)
    {
        if (handler is null) return;

        try
        {
            await handler(e);
        }
        catch (Exception ex)
        {
            // A broken notification must not stop tracking.
            _logger.LogError(ex, "Player event handler failed for {Player}.", e.Name);
        }
    }

    /// <summary>Currently open sessions, for the /players command.</summary>
    public async Task<IReadOnlyList<PlaySession>> GetOpenSessionsAsync(CancellationToken ct = default)
    {
        await using var db = await _dbFactory.CreateDbContextAsync(ct);
        return await db.PlaySessions
            .Where(s => s.EndedUtc == null)
            .OrderBy(s => s.StartedUtc)
            .ToListAsync(ct);
    }
}
