using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace DodoSSH.Client.Domain;

/// <summary>A decoded group payload, together with the schema version it was written at.</summary>
/// <param name="Group">The group.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVersion)
{
    /// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
    public bool IsReadOnly => SchemaVersion > HostGroupSecretCodec.CurrentSchemaVersion;
}

/// <summary>
/// Encodes and decodes the plaintext inside a group item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. What the
/// JSON envelope buys is a schema version, which is what lets a later build add a field without every older
/// client silently dropping it on the next edit — and this is the build that spent it, five fields at once.
/// See <see cref="HostSecretDocument.IsReadOnly"/>.
/// </remarks>
public static class HostGroupSecretCodec
{
    /// <summary>The first version, and the one a group with no newer field is still written at.</summary>
    public const int BaseSchemaVersion = 1;

    /// <summary>
    /// The version that introduced <see cref="HostGroupSecret.ParentId"/> and the four defaults.
    /// </summary>
    /// <remarks>
    /// One constant for five fields, because they arrive in the same build: no client exists that can read
    /// some of them and not the others, so a version per field would draw a distinction nothing can observe.
    /// <see cref="SchemaVersionFor"/> is still written as a maximum, which is what has to stay true when the
    /// sixth field arrives on its own.
    /// </remarks>
    public const int ParentAndDefaultsSchemaVersion = 2;

    /// <summary>The highest schema version this build can write.</summary>
    public const int CurrentSchemaVersion = ParentAndDefaultsSchemaVersion;

    /// <summary>Serialises a group to the bytes that get sealed.</summary>
    /// <exception cref="ArgumentException">The group is not valid for storage.</exception>
    public static byte[] Encode(HostGroupSecret group)
    {
        ArgumentNullException.ThrowIfNull(group);

        if (!group.TryValidate(out var reason))
        {
            throw new ArgumentException(reason, nameof(group));
        }

        var document = new HostGroupPayloadDocument
        {
            SchemaVersion = SchemaVersionFor(group),
            Label = group.Label,
            ParentId = group.ParentId,
            DefaultPort = group.DefaultPort,
            DefaultUsername = group.DefaultUsername,
            DefaultSshKeyId = group.DefaultSshKeyId,
            DefaultCredentialId = group.DefaultCredentialId,
        };

        return JsonSerializer.SerializeToUtf8Bytes(
            document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
    }

    /// <summary>
    /// The lowest schema version that can represent this group without losing anything.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Not simply <see cref="CurrentSchemaVersion"/>, which is what this codec used to stamp
    /// unconditionally — harmless while there was one field and one version, and not harmless from here on.
    /// The version is what makes an older client treat an
    /// item as read-only, so stamping the newest one regardless would mean that upgrading a single machine
    /// and then renaming <em>any</em> group made that group uneditable on every machine still on the old
    /// build. Emitting the lowest version that loses nothing confines that cost to the groups which actually
    /// nest or actually carry a default.
    /// </para>
    /// <para>
    /// It also means a group carrying none of the new fields encodes byte for byte as it did before they
    /// existed, so adding them did not make every group in every vault look like a change to the sync
    /// engine. <see cref="HostSecretCodec"/> has the same rule and it is the same argument; see
    /// <c>SchemaVersionFor</c> there for why it is a maximum over the fields present rather than a ladder.
    /// </para>
    /// </remarks>
    private static int SchemaVersionFor(HostGroupSecret group)
    {
        var version = BaseSchemaVersion;

        if (group.ParentId is not null
            || group.DefaultPort is not null
            || group.DefaultUsername is not null
            || group.DefaultSshKeyId is not null
            || group.DefaultCredentialId is not null)
        {
            version = Math.Max(version, ParentAndDefaultsSchemaVersion);
        }

        return version;
    }

    /// <summary>Parses a decrypted payload.</summary>
    /// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
    public static bool TryDecode(
        ReadOnlySpan<byte> payload,
        [NotNullWhen(true)] out HostGroupSecretDocument? document)
    {
        document = null;

        HostGroupPayloadDocument? parsed;
        try
        {
            parsed = JsonSerializer.Deserialize(
                payload, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
        }
        catch (JsonException)
        {
            return false;
        }

        if (parsed is null || parsed.SchemaVersion < 1)
        {
            return false;
        }

        var candidate = new HostGroupSecret
        {
            Label = parsed.Label ?? string.Empty,
            ParentId = parsed.ParentId,
            DefaultPort = parsed.DefaultPort,
            DefaultUsername = parsed.DefaultUsername,
            DefaultSshKeyId = parsed.DefaultSshKeyId,
            DefaultCredentialId = parsed.DefaultCredentialId,
        };

        if (!candidate.TryValidate(out _))
        {
            return false;
        }

        document = new HostGroupSecretDocument(candidate, parsed.SchemaVersion);
        return true;
    }
}

/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class HostGroupPayloadDocument
{
    public int SchemaVersion { get; set; }

    public string? Label { get; set; }

    /// <remarks>
    /// After <see cref="Label"/>, deliberately. Property order is the serialisation order, so appending
    /// keeps the bytes for the one field that existed before these identical — and a null is omitted
    /// entirely, which is what makes a flat group with no defaults encode exactly as it did before any of
    /// this existed.
    /// </remarks>
    public Guid? ParentId { get; set; }

    /// <inheritdoc cref="ParentId" />
    public int? DefaultPort { get; set; }

    /// <inheritdoc cref="ParentId" />
    public string? DefaultUsername { get; set; }

    /// <inheritdoc cref="ParentId" />
    public Guid? DefaultSshKeyId { get; set; }

    /// <inheritdoc cref="ParentId" />
    public Guid? DefaultCredentialId { get; set; }
}

[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HostGroupPayloadDocument))]
internal sealed partial class HostGroupPayloadJsonContext : JsonSerializerContext;
