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

namespace DodoSSH.Client.Domain;

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

/// <summary>
/// Encodes and decodes the plaintext inside a tag item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. One field
/// makes this look like ceremony around a string, and it is not: 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. <see cref="HostGroupSecretCodec"/> is the type that had to spend that budget, and the shape
/// here is the shape it had beforehand. See <see cref="HostSecretDocument.IsReadOnly"/>.
/// </remarks>
public static class TagSecretCodec
{
    /// <summary>The first version, and the one a tag with no newer field is still written at.</summary>
    public const int BaseSchemaVersion = 1;

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

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

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

        var document = new TagPayloadDocument
        {
            SchemaVersion = SchemaVersionFor(tag),
            Label = tag.Label,
        };

        return JsonSerializer.SerializeToUtf8Bytes(
            document, TagPayloadJsonContext.Default.TagPayloadDocument);
    }

    /// <summary>
    /// The lowest schema version that can represent this tag without losing anything.
    /// </summary>
    /// <remarks>
    /// With one field there is nothing to take a maximum over, and this returns a constant. It is a method
    /// anyway, because the alternative — stamping <see cref="CurrentSchemaVersion"/> at the call site — is
    /// indistinguishable from this today and becomes wrong the moment a second field exists: the version is
    /// what makes an older client treat an item as read-only, so a build that stamps the newest one
    /// unconditionally makes every tag in the vault uneditable everywhere as soon as one machine upgrades
    /// and renames one. <see cref="HostGroupSecretCodec"/> shipped without this and had to grow it; the
    /// method is here so the second field has an obvious place to go rather than a call site to notice.
    /// </remarks>
    private static int SchemaVersionFor(TagSecret tag) => BaseSchemaVersion;

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

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

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

        var candidate = new TagSecret { Label = parsed.Label ?? string.Empty };

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

        document = new TagSecretDocument(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 TagPayloadDocument
{
    public int SchemaVersion { get; set; }

    public string? Label { get; set; }
}

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