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

namespace DodoSSH.Client.Domain;

/// <summary>
/// One SSH configuration directive on a host.
/// </summary>
/// <remarks>
/// Equality treats the name case-insensitively, matching how SSH reads keywords. Without that, two
/// clients that resolved the same merge could end up holding <c>ServerAliveInterval</c> and
/// <c>serveraliveinterval</c>, compare their hosts as different, and push over each other forever
/// while agreeing on every actual value.
/// </remarks>
/// <param name="Name">Directive name, for example <c>ServerAliveInterval</c>.</param>
/// <param name="Value">Directive value, verbatim and case-sensitive.</param>
public sealed record HostOption(string Name, string Value)
{
    /// <summary>Defines directive identity: SSH keywords are case-insensitive.</summary>
    public static StringComparer NameComparer => StringComparer.OrdinalIgnoreCase;

    /// <inheritdoc />
    public bool Equals(HostOption? other) =>
        other is not null
        && NameComparer.Equals(Name, other.Name)
        && string.Equals(Value, other.Value, StringComparison.Ordinal);

    /// <inheritdoc />
    public override int GetHashCode() =>
        HashCode.Combine(NameComparer.GetHashCode(Name), Value.GetHashCode(StringComparison.Ordinal));
}

/// <summary>
/// A host's SSH directives: unique by name, held in name order.
/// </summary>
/// <remarks>
/// <para>
/// Both invariants are load-bearing for the merge. Uniqueness gives every value a stable key, which
/// is what lets two people add different directives to the same host and both survive — a
/// whole-collection comparison would make that a conflict and discard one side. Name order makes the
/// encoding deterministic, so re-encoding an unchanged host produces identical bytes and the sync
/// engine does not push a spurious update on every pass.
/// </para>
/// <para>
/// <b>The cost, stated plainly:</b> real <c>ssh_config</c> permits a directive to repeat, and for
/// most keywords the first occurrence wins. That cannot be represented here. It is a deliberate M1
/// limitation rather than an oversight — a repeated key has no merge key — and the import path must
/// surface it rather than quietly keeping one of the duplicates.
/// </para>
/// </remarks>
public sealed class HostOptions : IReadOnlyList<HostOption>, IEquatable<HostOptions>
{
    private readonly HostOption[] items;
    private readonly int hash;

    private HostOptions(HostOption[] items)
    {
        this.items = items;
        hash = ComputeHash(items);
    }

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

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

    /// <inheritdoc />
    public HostOption this[int index] => items[index];

    /// <summary>
    /// Builds a canonical collection, sorting by name.
    /// </summary>
    /// <exception cref="ArgumentException">A name repeats, or a name is blank.</exception>
    public static HostOptions Create(IEnumerable<HostOption> options)
    {
        if (!TryCreate(options, out var result, out var error))
        {
            throw new ArgumentException(error, nameof(options));
        }

        return result;
    }

    /// <summary>
    /// Builds a canonical collection, reporting rather than throwing on bad input.
    /// </summary>
    /// <remarks>
    /// The non-throwing overload exists because these values arrive from two places neither of which
    /// is trusted: a decrypted payload written by another client, and an imported
    /// <c>ssh_config</c>. Neither should be able to raise an exception from inside a sync pass.
    /// </remarks>
    public static bool TryCreate(
        IEnumerable<HostOption> options,
        [NotNullWhen(true)] out HostOptions? result,
        [NotNullWhen(false)] out string? error)
    {
        ArgumentNullException.ThrowIfNull(options);

        result = null;
        var ordered = options.ToArray();

        if (!Validate(ordered, out error))
        {
            return false;
        }

        Array.Sort(
            ordered,
            static (left, right) => HostOption.NameComparer.Compare(left.Name, right.Name));

        result = ordered.Length == 0 ? Empty : new HostOptions(ordered);
        return true;
    }

    /// <summary>Looks up a directive by name, case-insensitively as SSH treats keywords.</summary>
    public bool TryGetValue(string name, [NotNullWhen(true)] out string? value)
    {
        foreach (var option in items)
        {
            if (HostOption.NameComparer.Equals(option.Name, name))
            {
                value = option.Value;
                return true;
            }
        }

        value = null;
        return false;
    }

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

        if (other is null || other.items.Length != items.Length || other.hash != hash)
        {
            return false;
        }

        return items.AsSpan().SequenceEqual(other.items);
    }

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

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

    /// <inheritdoc />
    public IEnumerator<HostOption> GetEnumerator() => ((IEnumerable<HostOption>)items).GetEnumerator();

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

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

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

    /// <summary>Projects to a name-keyed map, for the per-directive merge.</summary>
    internal Dictionary<string, string> ToNameMap()
    {
        var map = new Dictionary<string, string>(items.Length, HostOption.NameComparer);

        foreach (var option in items)
        {
            map[option.Name] = option.Value;
        }

        return map;
    }

    private static bool Validate(HostOption[] ordered, [NotNullWhen(false)] out string? error)
    {
        foreach (var option in ordered)
        {
            if (option is null || string.IsNullOrWhiteSpace(option.Name))
            {
                error = "An SSH directive must have a name.";
                return false;
            }
        }

        var duplicate = ordered
            .GroupBy(o => o.Name, HostOption.NameComparer)
            .FirstOrDefault(g => g.Count() > 1);

        if (duplicate is not null)
        {
            error = $"The directive '{duplicate.Key}' appears more than once; M1 requires unique names.";
            return false;
        }

        error = null;
        return true;
    }

    private static int ComputeHash(HostOption[] items)
    {
        var accumulator = new HashCode();
        accumulator.Add(items.Length);

        foreach (var option in items)
        {
            accumulator.Add(option);
        }

        return accumulator.ToHashCode();
    }
}
