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

namespace DodoSSH.Client.Domain;

/// <summary>A decoded host payload, together with the schema version it was written at.</summary>
/// <param name="Host">The host.</param>
/// <param name="SchemaVersion">
/// The version the writing client used. May exceed
/// <see cref="HostSecretCodec.CurrentSchemaVersion"/>, which is the case this type exists to make
/// visible.
/// </param>
public sealed record HostSecretDocument(HostSecret Host, int SchemaVersion)
{
    /// <summary>
    /// Whether this payload was written by a newer client than the one reading it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Such an item is safe to <em>read</em> — every field this build knows about decodes normally —
    /// but must not be re-encoded, because fields added by the newer schema are not represented here
    /// and would be dropped on write. Silently losing a field a colleague filled in is exactly the
    /// class of bug that makes people stop trusting a synced vault.
    /// </para>
    /// <para>
    /// So the rule is: display it, refuse to edit it, and tell the user to update. Preserving unknown
    /// fields through a round trip was the alternative and it is worse — it means carrying opaque
    /// JSON inside the domain model, which then has no usable structural equality and so breaks the
    /// merge.
    /// </para>
    /// </remarks>
    public bool IsReadOnly => SchemaVersion > HostSecretCodec.CurrentSchemaVersion;
}

/// <summary>
/// Encodes and decodes the plaintext inside a host item's encrypted payload.
/// </summary>
/// <remarks>
/// <para>
/// JSON rather than the fixed binary layouts used elsewhere in the specification. The reasoning
/// differs because the constraints differ: those layouts are hashed or signed, so canonicality is
/// load-bearing, whereas this is only ever encrypted. What matters here instead is that the format
/// grows a field without a migration — and the one thing that must not happen is an old client
/// quietly dropping a field a new one wrote, which is what <see cref="HostSecretDocument.IsReadOnly"/>
/// prevents.
/// </para>
/// <para>
/// Encoding is deterministic: property order is fixed by declaration, and directives are held in a
/// sorted map. That matters because the sync engine decides whether to push by comparing values, and
/// a codec that produced different bytes for the same host would make every pass look like a change.
/// </para>
/// </remarks>
public static class HostSecretCodec
{
    /// <summary>The first version, and the one a host with no newer field is still written at.</summary>
    public const int BaseSchemaVersion = 1;

    /// <summary>The version that introduced <see cref="HostSecret.SshKeyId"/>.</summary>
    public const int SshKeyIdSchemaVersion = 2;

    /// <summary>The version that introduced <see cref="HostSecret.CredentialId"/>.</summary>
    public const int CredentialIdSchemaVersion = 3;

    /// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
    public const int GroupIdSchemaVersion = 4;

    /// <summary>
    /// The version that introduced inheritance: a null <see cref="HostSecret.Port"/> and
    /// <see cref="HostSecret.AsksForPassword"/>.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>The one version where "read-only on an older client" understates the cost.</b> Every field before
    /// this one is additive: an older build decodes the host, shows it, and refuses to save it. A null port
    /// is subtractive — the property is omitted, an older build's <c>int Port</c> reads 0, and
    /// <see cref="HostSecret.TryValidate"/> refuses the host outright. The item does not appear locked on
    /// that machine; it does not appear at all.
    /// </para>
    /// <para>
    /// Accepted rather than worked around, because the two workarounds are worse. Writing 22 into every host
    /// makes inheritance a lie the moment a group's default changes, and it is what inheritance exists to
    /// stop. Keeping a second non-inheriting port field beside this one would mean two ports per host and a
    /// rule about which wins, on every screen and on the wire. What confines the cost is
    /// <see cref="SchemaVersionFor"/>: only a host that actually inherits its port is written here.
    /// </para>
    /// <para>
    /// <see cref="HostSecret.AsksForPassword"/> shares this version because it arrives in the same build and
    /// answers the same question — it is what a host says instead of naming a binding, once naming nothing
    /// has come to mean "ask the group".
    /// </para>
    /// </remarks>
    public const int PortInheritSchemaVersion = 5;

    /// <summary>The version that introduced <see cref="HostSecret.TagIds"/>.</summary>
    /// <remarks>
    /// One past inheritance rather than sharing with it, because the two are independent: a host can wear
    /// tags without inheriting anything, and such a host stays decodable on a build that knows 5. That
    /// distinction is unobservable today — both shipped together — and it is stated anyway, because the rule
    /// this file follows is one version per field and the exception would have to be re-justified by whoever
    /// adds the seventh.
    /// </remarks>
    public const int TagIdsSchemaVersion = 6;

    /// <summary>The version that introduced <see cref="HostSecret.PinnedPaths"/>.</summary>
    /// <remarks>
    /// One past tags rather than sharing with them, for the reason <see cref="TagIdsSchemaVersion"/> gives
    /// for sharing with inheritance instead of standing alone: the two fields are independent, a host can
    /// pin paths without wearing a single tag, and stating the version such a host would actually need
    /// keeps the rule in <see cref="SchemaVersionFor"/> a maximum over what is genuinely present rather
    /// than a coincidence of what shipped together.
    /// </remarks>
    public const int PinnedPathsSchemaVersion = 7;

    /// <summary>The highest schema version this build can write.</summary>
    /// <remarks>
    /// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
    /// maximum. A new field added below this line has to be named here too.
    /// </remarks>
    public const int CurrentSchemaVersion = PinnedPathsSchemaVersion;

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

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

        var options = new SortedDictionary<string, string>(HostOption.NameComparer);
        foreach (var option in host.Options)
        {
            options[option.Name] = option.Value;
        }

        var document = new HostPayloadDocument
        {
            SchemaVersion = SchemaVersionFor(host),
            Label = host.Label,
            Hostname = host.Hostname,
            Port = host.Port,
            Username = host.Username,
            Notes = host.Notes,
            JumpHostIds = [.. host.JumpHostIds],
            Options = options,
            RelayEnabled = host.RelayEnabled,
            SshKeyId = host.SshKeyId,
            CredentialId = host.CredentialId,
            GroupId = host.GroupId,

            // Null rather than false, so a host that never said anything about this encodes exactly as it
            // did before the field existed. See HostSecret.AsksForPassword.
            AsksForPassword = host.AsksForPassword is true ? true : null,

            // Null rather than an empty array, for the same reason and with a wider blast radius: an empty
            // [] here would land in every host in every vault and make the first sync after the upgrade
            // read as though every one of them had changed.
            TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds],

            // Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
            // as it did before this field existed.
            PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
        };

        return JsonSerializer.SerializeToUtf8Bytes(
            document, HostPayloadJsonContext.Default.HostPayloadDocument);
    }

    /// <summary>
    /// The lowest schema version that can represent this host without losing anything.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Not simply <see cref="CurrentSchemaVersion"/>, and in a shared vault the difference is the whole
    /// point. The version is what makes an older client treat an item as read-only, so stamping the newest
    /// one unconditionally would mean that upgrading a single machine and then touching <em>any</em> host —
    /// renaming it, changing a port — made that host uneditable on every machine that had not been upgraded
    /// yet. Emitting the lowest version that loses nothing confines that cost to the hosts which actually
    /// use the newer field.
    /// </para>
    /// <para>
    /// The rule generalises, and every field added since has followed it: a host is written at the version
    /// that introduced the newest field it actually carries. It also means the bytes for a host that binds
    /// nothing are identical to what this codec produced before either binding existed, so adding the fields
    /// did not make every host in every vault look like a change to the sync engine.
    /// </para>
    /// <para>
    /// <b>A maximum, not a ladder, and the difference arrived with <see cref="HostSecret.GroupId"/>.</b> The
    /// two authentication bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so
    /// while they were the only versioned fields, a <c>switch</c> that returned the first match was
    /// indistinguishable from the rule and read more clearly. A group is orthogonal to both: a host can name
    /// a credential <em>and</em> a group, and the ladder would have answered 3 for it, writing a version that
    /// cannot represent the group it just wrote. An older client would then decode that host as editable and
    /// drop the field on the next save.
    /// </para>
    /// <para>
    /// Written as a maximum over the fields actually present, which is the general form of the same rule and
    /// stays correct however the next field relates to these.
    /// </para>
    /// </remarks>
    private static int SchemaVersionFor(HostSecret host)
    {
        var version = BaseSchemaVersion;

        if (host.SshKeyId is not null)
        {
            version = Math.Max(version, SshKeyIdSchemaVersion);
        }

        if (host.CredentialId is not null)
        {
            version = Math.Max(version, CredentialIdSchemaVersion);
        }

        if (host.GroupId is not null)
        {
            version = Math.Max(version, GroupIdSchemaVersion);
        }

        // Both halves of inheritance, and this is the branch that loses an item rather than locking one if
        // it is forgotten — see PortInheritSchemaVersion. A host stamped at 4 with its port omitted is a
        // host an older client deletes from its own view.
        if (host.Port is null || host.AsksForPassword is true)
        {
            version = Math.Max(version, PortInheritSchemaVersion);
        }

        if (host.TagIds.Count > 0)
        {
            version = Math.Max(version, TagIdsSchemaVersion);
        }

        if (host.PinnedPaths.Count > 0)
        {
            version = Math.Max(version, PinnedPathsSchemaVersion);
        }

        return version;
    }

    /// <summary>
    /// Parses a decrypted payload.
    /// </summary>
    /// <remarks>
    /// Returns <see langword="false"/> rather than throwing on anything malformed. These bytes
    /// authenticated under a key only vault members hold, so a failure here is not an attack — it is
    /// a bug in some client, or a truncated write. Either way it must degrade to one unreadable item
    /// rather than an exception that aborts the whole sync pass and strands every other change.
    /// </remarks>
    public static bool TryDecode(
        ReadOnlySpan<byte> payload,
        [NotNullWhen(true)] out HostSecretDocument? document)
    {
        document = null;

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

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

        if (!TryBuild(parsed, out var host))
        {
            return false;
        }

        document = new HostSecretDocument(host, parsed.SchemaVersion);
        return true;
    }

    private static bool TryBuild(
        HostPayloadDocument parsed,
        [NotNullWhen(true)] out HostSecret? host)
    {
        host = null;

        var directives = (parsed.Options ?? [])
            .Select(entry => new HostOption(entry.Key, entry.Value));

        if (!HostOptions.TryCreate(directives, out var options, out _))
        {
            return false;
        }

        var candidate = new HostSecret
        {
            Label = parsed.Label ?? string.Empty,
            Hostname = parsed.Hostname ?? string.Empty,
            Port = parsed.Port,
            Username = parsed.Username,
            Notes = parsed.Notes,
            JumpHostIds = JumpChain.Create(parsed.JumpHostIds ?? []),
            Options = options,
            RelayEnabled = parsed.RelayEnabled,
            SshKeyId = parsed.SshKeyId,
            CredentialId = parsed.CredentialId,
            GroupId = parsed.GroupId,

            // False folds back to null: they say the same thing, and letting both onto the record would
            // give the merge two spellings of one state to report as a change nobody made.
            AsksForPassword = parsed.AsksForPassword is true ? true : null,

            TagIds = TagSet.Create(parsed.TagIds ?? []),

            PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
        };

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

        host = candidate;
        return true;
    }
}

/// <summary>
/// The serialised shape. Mutable and nullable because it models untrusted input.
/// </summary>
/// <remarks>
/// Deliberately separate from <see cref="HostSecret"/>. A single type would force the domain model to
/// carry the serialiser's requirements — a parameterless constructor, settable properties, nullable
/// everything — and would let a decode failure produce a half-built host that looks valid to
/// everything downstream.
/// </remarks>
internal sealed class HostPayloadDocument
{
    public int SchemaVersion { get; set; }

    public string? Label { get; set; }

    public string? Hostname { get; set; }

    /// <remarks>
    /// Nullable, which is what removes the key from the JSON for a host that inherits its port. It is also
    /// the one property here whose absence an older build cannot survive: it deserialises as
    /// <see langword="int"/> 0 there, which <see cref="HostSecret.TryValidate"/> refuses. See
    /// <see cref="HostSecretCodec.PortInheritSchemaVersion"/>.
    /// </remarks>
    public int? Port { get; set; }

    public string? Username { get; set; }

    public string? Notes { get; set; }

    public Guid[]? JumpHostIds { get; set; }

    /// <remarks>
    /// Sorted, so serialisation order is defined by the type rather than by insertion order — a
    /// plain <see cref="Dictionary{TKey,TValue}"/> does not guarantee enumeration order, and this
    /// encoding has to be reproducible.
    /// </remarks>
    public SortedDictionary<string, string>? Options { get; set; }

    public bool RelayEnabled { get; set; }

    /// <remarks>
    /// Last, deliberately. Property order is the serialisation order, so appending keeps the bytes for every
    /// field that existed before this one byte-identical — and a null is omitted entirely, which is what
    /// makes a host with no key encode exactly as it did before the field existed.
    /// </remarks>
    public Guid? SshKeyId { get; set; }

    /// <inheritdoc cref="SshKeyId" />
    public Guid? CredentialId { get; set; }

    /// <inheritdoc cref="SshKeyId" />
    public Guid? GroupId { get; set; }

    /// <inheritdoc cref="SshKeyId" />
    public bool? AsksForPassword { get; set; }

    /// <remarks>
    /// Last, and it must stay last for the reason <see cref="SshKeyId"/> gives. Null when the host wears no
    /// tags, never <c>[]</c> — an empty array would be a new key in the JSON of every host in every vault,
    /// which the sync engine would read as every host having changed.
    /// </remarks>
    public Guid[]? TagIds { get; set; }

    /// <remarks>
    /// Last, and null rather than <c>[]</c>, for exactly the reasons <see cref="TagIds"/> gives — it is the
    /// newer of the two and follows the same rule.
    /// </remarks>
    public string[]? PinnedPaths { get; set; }
}

[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    // An unknown member means a newer client wrote a field this build has no concept of. Skipping it
    // is right; the guard against losing it lives in HostSecretDocument.IsReadOnly.
    UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HostPayloadDocument))]
internal sealed partial class HostPayloadJsonContext : JsonSerializerContext;
