using TeamsISO.Engine.Domain;

namespace TeamsISO.Engine.Discovery;

/// <summary>
/// Maintains the operator-facing participant list, applying the rename heuristic
/// from the v1 spec: if a participant source disappears and another participant
/// source with the same MachineName appears within <see cref="_renameWindow"/>,
/// the existing <see cref="Participant.Id"/> transfers to the new source.
/// </summary>
public sealed class ParticipantTracker
{
    private readonly TimeSpan _renameWindow;
    private readonly Func<DateTimeOffset> _now;
    private readonly List<MutableParticipant> _participants = new();
    private readonly List<RecentlyRemoved> _recentlyRemoved = new();

    public ParticipantTracker(TimeSpan renameWindow, Func<DateTimeOffset> now)
    {
        _renameWindow = renameWindow;
        _now = now;
    }

    public IReadOnlyList<Participant> Participants =>
        _participants.Select(m => m.ToRecord()).ToList();

    public void Apply(DiscoveryEvent ev)
    {
        switch (ev)
        {
            case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.Participant:
                HandleAdded(a.Source);
                break;
            case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.ActiveSpeaker:
                HandleAutoMixAdded(a.Source);
                break;
            case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.Participant:
                HandleRemoved(r.Source);
                break;
            case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.ActiveSpeaker:
                HandleAutoMixRemoved(r.Source);
                break;
        }
    }

    private void HandleAdded(NdiSource source)
    {
        var now = _now();
        PruneRecentlyRemoved(now);

        // Idempotency guard: if the same FullName is already tracked (because the
        // operator hit Refresh and discovery is re-emitting everything), refresh
        // LastSeen + DisplayName in place instead of minting a duplicate row.
        var alreadyLive = _participants.FirstOrDefault(p =>
            p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName);
        if (alreadyLive is not null)
        {
            alreadyLive.DisplayName = source.DisplayName!;
            alreadyLive.CurrentSource = source;
            alreadyLive.LastSeen = now;
            return;
        }

        var match = _recentlyRemoved.FirstOrDefault(rr => rr.MachineName == source.MachineName);
        if (match is not null)
        {
            var existing = _participants.First(p => p.Id == match.Id);
            existing.DisplayName = source.DisplayName!;
            existing.CurrentSource = source;
            existing.LastSeen = now;
            _recentlyRemoved.Remove(match);
            return;
        }

        _participants.Add(new MutableParticipant(
            Id: Guid.NewGuid(),
            DisplayName: source.DisplayName!,
            CurrentSource: source,
            FirstSeen: now,
            LastSeen: now));
    }

    /// <summary>
    /// Surfaces Teams' auto-mix output (the "Active Speaker" feed in current Teams,
    /// or the legacy bare "(Teams)" source) as a special row in the participant list
    /// with the synthetic display name "Active Speaker". The operator can then route
    /// it to its own normalized ISO via the same enable toggle every other participant
    /// uses. Id is deterministic from "auto-mix:&lt;machine&gt;" so the row's identity is
    /// stable across discovery churn.
    /// </summary>
    private void HandleAutoMixAdded(NdiSource source)
    {
        var now = _now();
        PruneRecentlyRemoved(now);

        var stableId = DeterministicGuid("auto-mix:" + source.MachineName);
        var existing = _participants.FirstOrDefault(p => p.Id == stableId);
        if (existing is not null)
        {
            existing.CurrentSource = source;
            existing.LastSeen = now;
            return;
        }

        _participants.Add(new MutableParticipant(
            Id: stableId,
            DisplayName: "Active Speaker",
            CurrentSource: source,
            FirstSeen: now,
            LastSeen: now));
    }

    /// <summary>
    /// Stable Guid derived from a string. Used to give the auto-mix synthetic
    /// participant a consistent Id across discovery cycles (so re-add doesn't
    /// duplicate the row, and the operator's ISO assignment persists).
    /// </summary>
    private static Guid DeterministicGuid(string input)
    {
        var hash = System.Security.Cryptography.SHA1.HashData(System.Text.Encoding.UTF8.GetBytes(input));
        var bytes = new byte[16];
        Buffer.BlockCopy(hash, 0, bytes, 0, 16);
        // Set the version (5) and variant bits per RFC 4122 so the value is a valid v5 GUID.
        bytes[6] = (byte)((bytes[6] & 0x0F) | 0x50);
        bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
        return new Guid(bytes);
    }

    private void HandleRemoved(NdiSource source)
    {
        var now = _now();
        var existing = _participants.FirstOrDefault(p =>
            p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName);
        if (existing is null)
            return;

        existing.CurrentSource = null;
        _recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now));
    }

    /// <summary>
    /// Removes the synthetic auto-mix row's CurrentSource. Crucially does NOT add to
    /// <see cref="_recentlyRemoved"/> — the auto-mix row's identity is already stable
    /// via the deterministic v5 GUID, so re-add restores it without needing the
    /// rename-window heuristic, and we must not let an active-speaker disappearance
    /// poison the rename matcher for a Participant joining the same machine within
    /// the window.
    /// </summary>
    private void HandleAutoMixRemoved(NdiSource source)
    {
        var stableId = DeterministicGuid("auto-mix:" + source.MachineName);
        var existing = _participants.FirstOrDefault(p => p.Id == stableId);
        if (existing is null) return;
        existing.CurrentSource = null;
        existing.LastSeen = _now();
    }

    private void PruneRecentlyRemoved(DateTimeOffset now)
    {
        _recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow);
    }

    private sealed class MutableParticipant
    {
        public Guid Id { get; init; }
        public string DisplayName { get; set; }
        public NdiSource? CurrentSource { get; set; }
        public DateTimeOffset FirstSeen { get; init; }
        public DateTimeOffset LastSeen { get; set; }

        public MutableParticipant(Guid Id, string DisplayName, NdiSource? CurrentSource,
            DateTimeOffset FirstSeen, DateTimeOffset LastSeen)
        {
            this.Id = Id;
            this.DisplayName = DisplayName;
            this.CurrentSource = CurrentSource;
            this.FirstSeen = FirstSeen;
            this.LastSeen = LastSeen;
        }

        public Participant ToRecord() =>
            new(Id, DisplayName, CurrentSource, FirstSeen, LastSeen);
    }

    private sealed record RecentlyRemoved(Guid Id, string MachineName, DateTimeOffset RemovedAt);
}
