using System.Text.Json;
using System.Text.Json.Serialization;

namespace DodoSSH.Contracts;

/// <summary>
/// Source-generated JSON contracts, shared by the API and the client so both sides serialise
/// identically.
/// </summary>
/// <remarks>
/// <para>
/// Source generation rather than reflection: no startup reflection cost, and it keeps the
/// assembly trim- and AOT-compatible.
/// </para>
/// <para>
/// Always use <see cref="ResponseOptions"/> or <see cref="StrictRequestOptions"/>. Constructing
/// <see cref="JsonSerializerOptions"/> by hand and merely pointing its resolver at this context
/// silently discards every setting configured here — notably
/// <see cref="JsonNumberHandling.Strict"/>, which <see cref="JsonSerializerDefaults.Web"/>
/// otherwise replaces with <see cref="JsonNumberHandling.AllowReadingFromString"/>. That failure
/// is invisible until two implementations disagree about whether <c>"1"</c> is a valid number.
/// </para>
/// </remarks>
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    NumberHandling = JsonNumberHandling.Strict,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    UseStringEnumConverter = true)]
[JsonSerializable(typeof(MetaResponse))]
[JsonSerializable(typeof(DodoSshConfiguration))]
[JsonSerializable(typeof(MeResponse))]
[JsonSerializable(typeof(EnrollmentRequest))]

// Explicit, although it is reachable through EnrollmentRequest. The server serialises a statement
// on its own when persisting it, and a resolver that had only inferred it would fail at runtime.
[JsonSerializable(typeof(KeyStatement))]
[JsonSerializable(typeof(EnrollmentResponse))]
[JsonSerializable(typeof(RegisterDeviceRequest))]
[JsonSerializable(typeof(RegisterDeviceResponse))]
[JsonSerializable(typeof(DirectoryEntry))]
[JsonSerializable(typeof(IReadOnlyList<DirectoryEntry>))]
[JsonSerializable(typeof(VaultSummary))]
[JsonSerializable(typeof(TeamSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamSummary>))]
[JsonSerializable(typeof(CreateTeamRequest))]
[JsonSerializable(typeof(UpdateTeamRequest))]
[JsonSerializable(typeof(TransferTeamOwnershipRequest))]
[JsonSerializable(typeof(TeamMemberSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
[JsonSerializable(typeof(AddTeamMemberRequest))]
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
[JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(UpdateVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))]
[JsonSerializable(typeof(RekeyVaultRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))]
[JsonSerializable(typeof(KeyLogPage))]
[JsonSerializable(typeof(SyncPullRequest))]
[JsonSerializable(typeof(SyncPullResponse))]
[JsonSerializable(typeof(SyncPushRequest))]
[JsonSerializable(typeof(SyncPushResponse))]
[JsonSerializable(typeof(SyncChange))]
// Registered in its own right, not only as a member of the sync DTOs: the client's local
// cache seals this record under the LocalCacheKey and needs its type info directly.
[JsonSerializable(typeof(SyncPlaintextFields))]

// The event socket's only frame type. Registered although nothing else references it: frames are
// written straight onto a WebSocket rather than through a response body, so the resolver never
// infers it from an endpoint's signature the way it does for every DTO above.
[JsonSerializable(typeof(VaultEvent))]
[JsonSerializable(typeof(RelayTicketRequest))]
[JsonSerializable(typeof(RelayTicketResponse))]
[JsonSerializable(typeof(RelaySessionSummary))]
[JsonSerializable(typeof(IReadOnlyList<RelaySessionSummary>))]
public sealed partial class DodoSshJsonContext : JsonSerializerContext
{
    /// <summary>
    /// The canonical options for writing responses and for reading them on the client.
    /// </summary>
    /// <remarks>
    /// Unmapped members are tolerated here on purpose: an older client must still be able to
    /// read a newer server's response rather than failing on a field it does not know about.
    /// </remarks>
    public static JsonSerializerOptions ResponseOptions => Default.Options;

    /// <summary>
    /// Options for deserialising inbound request bodies.
    /// </summary>
    /// <remarks>
    /// Identical to <see cref="ResponseOptions"/> except that unmapped members are rejected, so
    /// a client bug — a renamed or misspelled property — surfaces as a 400 rather than as a
    /// silently missing value that later looks like data loss. Derived by copying rather than
    /// constructed fresh, so it inherits every source-generated setting.
    /// </remarks>
    public static JsonSerializerOptions StrictRequestOptions => LazyStrictRequestOptions.Value;

    /// <summary>
    /// Applies these settings to options this process does not own.
    /// </summary>
    /// <remarks>
    /// <para>
    /// ASP.NET Core exposes its JSON options as a get-only <see cref="JsonSerializerOptions"/> already
    /// constructed from <see cref="JsonSerializerDefaults.Web"/>, so a host cannot simply hand it
    /// <see cref="StrictRequestOptions"/> — it has to be mutated in place. That copying lives here, next
    /// to the settings it has to mirror, so adding a setting above is one edit rather than two in different
    /// projects with nothing connecting them.
    /// </para>
    /// <para>
    /// <b>Why this matters more than it looks.</b> Without it the framework defaults apply, and those
    /// serialise an enum as a number. Every request DTO carrying one — which is the whole sync surface —
    /// then fails to bind against a client that writes the specified string form, with a 400 that names
    /// only the parameter. Nothing catches it if the tests post with their own default options, because
    /// both sides then agree on a form the specification never described.
    /// </para>
    /// </remarks>
    /// <param name="target">Options to bring into line. Must not already be read-only.</param>
    public static void ApplyTo(JsonSerializerOptions target)
    {
        ArgumentNullException.ThrowIfNull(target);

        var source = StrictRequestOptions;

        target.PropertyNamingPolicy = source.PropertyNamingPolicy;
        target.PropertyNameCaseInsensitive = source.PropertyNameCaseInsensitive;
        target.NumberHandling = source.NumberHandling;
        target.DefaultIgnoreCondition = source.DefaultIgnoreCondition;
        target.UnmappedMemberHandling = source.UnmappedMemberHandling;
        target.DictionaryKeyPolicy = source.DictionaryKeyPolicy;

        foreach (var converter in source.Converters)
        {
            target.Converters.Add(converter);
        }

        // Inserted at the front rather than assigned, so contract types use the source-generated metadata
        // while any resolver the caller already installed keeps handling everything else — ProblemDetails
        // among them, which a host must still be able to write.
        //
        // Options carrying no resolver at all end up with a chain containing only this context, and
        // anything outside the contract then throws NotSupportedException. That is deliberate: adding a
        // reflection fallback here would quietly cost trimmability, which is half the reason this context
        // is source-generated. ASP.NET Core's own JSON options already have one.
        target.TypeInfoResolverChain.Insert(0, Default);
    }

    /// <remarks>
    /// Lazy, not a static initialiser. The generated <c>Default</c> property is a static of this
    /// same class, so reading it from this type's initialiser is a cycle: the accessor runs
    /// before its backing field is assigned and yields null.
    /// </remarks>
    private static readonly Lazy<JsonSerializerOptions> LazyStrictRequestOptions =
        new(CreateStrictRequestOptions, LazyThreadSafetyMode.ExecutionAndPublication);

    private static JsonSerializerOptions CreateStrictRequestOptions()
    {
        var options = new JsonSerializerOptions(Default.Options)
        {
            UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
        };

        options.MakeReadOnly();
        return options;
    }
}
