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

namespace DodoSSH.Client.Domain;

/// <summary>
/// The remote directory paths pinned on a host, in the order the user arranged them.
/// </summary>
/// <remarks>
/// <para>
/// A dedicated type rather than a list of strings, for the reason <see cref="JumpChain"/> and
/// <see cref="TagSet"/> both give: 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>Ordered, unlike <see cref="TagSet"/>; deduplicated, unlike <see cref="JumpChain"/>.</b> Order is
/// exactly what a pinned-path list is for: it is what a quick-access menu draws top to bottom, and a user
/// who drags their most-used path to the top has expressed something a sorted set would erase. So this
/// preserves insertion order rather than sorting at construction. But two pins of the same path are not
/// two shortcuts, only one typed twice, so this still dedupes — keeping the first occurrence — by ordinal
/// comparison, because the path is meaningful only to the remote shell and that shell may well be running
/// on a case-sensitive filesystem; folding case here would silently collapse two different directories
/// into one entry.
/// </para>
/// <para>
/// Empty and over-length paths are not rejected at construction, for the reason <see cref="TagSet"/>
/// gives for its empty ids: 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 PinnedPathList : IReadOnlyList<string>, IEquatable<PinnedPathList>
{
    private readonly string[] paths;
    private readonly int hash;

    private PinnedPathList(string[] paths)
    {
        this.paths = paths;
        hash = ComputeHash(paths);
    }

    /// <summary>No pinned paths.</summary>
    public static PinnedPathList Empty { get; } = new([]);

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

    /// <inheritdoc />
    public string this[int index] => paths[index];

    /// <summary>Copies a sequence of paths, keeping order and the first occurrence of any repeat.</summary>
    public static PinnedPathList Create(IEnumerable<string> paths)
    {
        ArgumentNullException.ThrowIfNull(paths);

        return Canonicalise([.. paths]);
    }

    /// <summary>Copies a span of paths, keeping order and the first occurrence of any repeat.</summary>
    public static PinnedPathList Create(ReadOnlySpan<string> paths) => Canonicalise(paths.ToArray());

    /// <summary>Whether this host pins the given path, compared ordinally.</summary>
    public bool Contains(string path) => Array.IndexOf(paths, path) >= 0;

    /// <summary>
    /// The list as a map from path to path, in list order, which is the shape
    /// <see cref="ThreeWayMerge.Map"/> takes.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The value repeats the key for the reason <see cref="TagSet.ToIdMap"/>'s does: a per-key merge
    /// resolves presence and absence independently, which is set semantics with removals, so two machines
    /// pinning different paths on the same host both keep theirs, and a removal on one side is reported
    /// rather than silently undone.
    /// </para>
    /// <para>
    /// Built in list order rather than any canonical one, and that is load-bearing rather than incidental.
    /// <see cref="ThreeWayMerge.Map"/> unions keys by walking the ancestor's map, then the local side's,
    /// then the remote's, each in the order its dictionary enumerates — so an ordered map here is what lets
    /// <see cref="HostSecretMerge"/> reproduce "base order, then additions" after the merge without doing
    /// any ordering of its own.
    /// </para>
    /// </remarks>
    public IReadOnlyDictionary<string, string> ToPathMap()
    {
        var map = new Dictionary<string, string>(paths.Length, StringComparer.Ordinal);

        foreach (var path in paths)
        {
            map[path] = path;
        }

        return map;
    }

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

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

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

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

    /// <inheritdoc />
    public IEnumerator<string> GetEnumerator() => ((IEnumerable<string>)paths).GetEnumerator();

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

    /// <summary>The paths, without copying, in list order.</summary>
    public ReadOnlySpan<string> AsSpan() => paths;

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

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

    /// <remarks>
    /// Order-preserving, unlike <see cref="TagSet"/>'s canonicalisation: the first occurrence of a path is
    /// kept in place and later repeats are dropped, rather than the whole array being sorted.
    /// </remarks>
    private static PinnedPathList Canonicalise(string[] paths)
    {
        if (paths.Length == 0)
        {
            return Empty;
        }

        var seen = new HashSet<string>(paths.Length, StringComparer.Ordinal);
        var written = 0;

        for (var read = 0; read < paths.Length; read++)
        {
            if (seen.Add(paths[read]))
            {
                paths[written++] = paths[read];
            }
        }

        return new PinnedPathList(written == paths.Length ? paths : paths[..written]);
    }

    private static int ComputeHash(string[] paths)
    {
        // Order-sensitive, because order is the thing this type exists to preserve.
        var accumulator = new HashCode();
        accumulator.Add(paths.Length);

        foreach (var path in paths)
        {
            accumulator.Add(path, StringComparer.Ordinal);
        }

        return accumulator.ToHashCode();
    }
}
