using System.Collections;
using System.Diagnostics.CodeAnalysis;

namespace DodoSSH.Client.Domain;

/// <summary>
/// The tags a host wears, as item ids, in no meaningful order.
/// </summary>
/// <remarks>
/// <para>
/// A dedicated type rather than a list of ids, for the reason <see cref="JumpChain"/> gives: a plain
/// <see cref="IReadOnlyList{T}"/> on a record gets reference equality from the compiler-generated
/// <c>Equals</c>, so every host would read as changed on every sync pass and two identical edits would
/// register as a conflict.
/// </para>
/// <para>
/// <b>Unlike a jump chain, this is a set, and the difference is the whole point.</b> A route's order is its
/// meaning; a tag list's order is an artefact of which chip the user tapped first. So this sorts and
/// deduplicates at construction, which makes two users who added the same two tags in opposite orders
/// produce the same value — and therefore no change to push and nothing to merge. It also means the merge
/// may treat it as a set: see <see cref="ToIdMap"/>.
/// </para>
/// <para>
/// Empty ids are not rejected at construction. They arrive from a decrypted payload written by another
/// client, and a constructor that threw would turn one bad item into a failed sync pass for every other item
/// behind it. <see cref="HostSecret.TryValidate"/> is where that is caught.
/// </para>
/// </remarks>
public sealed class TagSet : IReadOnlyList<Guid>, IEquatable<TagSet>
{
    private readonly Guid[] ids;
    private readonly int hash;

    private TagSet(Guid[] ids)
    {
        this.ids = ids;
        hash = ComputeHash(ids);
    }

    /// <summary>No tags.</summary>
    public static TagSet Empty { get; } = new([]);

    /// <inheritdoc />
    public int Count => ids.Length;

    /// <inheritdoc />
    public Guid this[int index] => ids[index];

    /// <summary>Copies a sequence of tag ids, sorting and removing repeats.</summary>
    public static TagSet Create(IEnumerable<Guid> ids)
    {
        ArgumentNullException.ThrowIfNull(ids);

        return Canonicalise([.. ids]);
    }

    /// <summary>Copies a span of tag ids, sorting and removing repeats.</summary>
    public static TagSet Create(ReadOnlySpan<Guid> ids) => Canonicalise(ids.ToArray());

    /// <summary>Whether this host wears the given tag.</summary>
    public bool Contains(Guid id) => Array.BinarySearch(ids, id) >= 0;

    /// <summary>
    /// The set as a map from tag id to tag id, which is the shape <see cref="ThreeWayMerge.Map"/> takes.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The value repeats the key deliberately. A per-key merge resolves presence and absence independently,
    /// which is set semantics with removals — so two people each adding a different tag to one host both
    /// keep theirs, and a removal on one side is reported rather than silently undone. A whole-value merge
    /// would drop one side outright.
    /// </para>
    /// <para>
    /// <b>This is the reason there is no <c>HostTag</c> join item.</b> The one thing a join buys over a set
    /// on the host — two machines tagging the same host without either losing — is exactly what a keyed
    /// merge already gives. See <see cref="TagSecret"/>.
    /// </para>
    /// <para>
    /// Because the value is the key, no key can ever have two different values, so a conflict here can only
    /// ever be one side adding what the other removed. That is the only disagreement a set can have.
    /// </para>
    /// </remarks>
    public IReadOnlyDictionary<Guid, Guid> ToIdMap() => ids.ToDictionary(id => id);

    /// <inheritdoc />
    public bool Equals(TagSet? other)
    {
        if (ReferenceEquals(this, other))
        {
            return true;
        }

        return other is not null
            && other.hash == hash
            && ids.AsSpan().SequenceEqual(other.ids);
    }

    /// <inheritdoc />
    public override bool Equals(object? obj) => Equals(obj as TagSet);

    /// <inheritdoc />
    public override int GetHashCode() => hash;

    /// <inheritdoc />
    public IEnumerator<Guid> GetEnumerator() => ((IEnumerable<Guid>)ids).GetEnumerator();

    /// <inheritdoc />
    IEnumerator IEnumerable.GetEnumerator() => ids.GetEnumerator();

    /// <summary>The ids, without copying, in sorted order.</summary>
    public ReadOnlySpan<Guid> AsSpan() => ids;

    /// <summary>Contents equality, tolerating nulls on either side.</summary>
    [SuppressMessage(
        "Usage",
        "CA2225:Operator overloads have named alternates",
        Justification = "Equals(TagSet) is the named alternate.")]
    public static bool operator ==(TagSet? left, TagSet? right) =>
        left is null ? right is null : left.Equals(right);

    /// <summary>Contents inequality.</summary>
    public static bool operator !=(TagSet? left, TagSet? right) => !(left == right);

    /// <remarks>
    /// Sorting is what makes the sequence comparison below a set comparison, and deduplicating is what stops
    /// a payload that repeats an id from comparing unequal to the same set written once. Both happen here,
    /// once, rather than at every comparison.
    /// </remarks>
    private static TagSet Canonicalise(Guid[] ids)
    {
        if (ids.Length == 0)
        {
            return Empty;
        }

        Array.Sort(ids);

        var written = 1;
        for (var read = 1; read < ids.Length; read++)
        {
            if (ids[read] != ids[written - 1])
            {
                ids[written++] = ids[read];
            }
        }

        return new TagSet(written == ids.Length ? ids : ids[..written]);
    }

    private static int ComputeHash(Guid[] ids)
    {
        // Order-sensitive over an already-sorted array, which is order-insensitive over the set. Written
        // this way rather than by summing or xoring hashes because those collide on swapped pairs.
        var accumulator = new HashCode();
        accumulator.Add(ids.Length);

        foreach (var id in ids)
        {
            accumulator.Add(id);
        }

        return accumulator.ToHashCode();
    }
}
