using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

namespace DodoSSH.Domain.Sync;

/// <summary>
/// An opaque, integrity-tagged position in a vault's change log.
/// </summary>
/// <remarks>
/// <para>
/// Clients must never construct or modify one. The tag exists so a tampered cursor is
/// <em>rejected</em> rather than silently mis-serving: an untagged cursor would let a caller
/// rewind to sequence 0 and re-read everything, or skip forward and permanently miss changes
/// while believing it was up to date. Silent data loss is far worse than an error.
/// </para>
/// <para>
/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be
/// replayed against another. See ADR 0003.
/// </para>
/// </remarks>
public static class SyncCursor
{
    private const string Version = "v1";
    private const char Separator = '|';

    /// <summary>Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.</summary>
    private const int TagLength = 16;

    /// <summary>Minimum signing key length.</summary>
    public const int MinimumKeyLength = 32;

    /// <summary>
    /// Encodes a position.
    /// </summary>
    /// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
    /// <param name="vaultId">Vault the cursor belongs to.</param>
    /// <param name="sequence">Last change sequence the client has consumed.</param>
    public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence)
    {
        RequireKey(signingKey);

        if (sequence < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
        }

        var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence));

        var buffer = new byte[payload.Length + TagLength];
        payload.CopyTo(buffer, 0);
        ComputeTag(signingKey, payload, buffer.AsSpan(payload.Length, TagLength));

        return Base64Url.Encode(buffer);
    }

    /// <summary>
    /// Decodes and verifies a cursor.
    /// </summary>
    /// <remarks>
    /// Returns false for anything malformed, mis-tagged, or issued for a different vault. It never
    /// throws on bad input: cursors arrive from clients, so rejection is an expected outcome.
    /// </remarks>
    /// <param name="signingKey">Deployment cursor signing key.</param>
    /// <param name="cursor">The cursor to verify.</param>
    /// <param name="expectedVaultId">Vault the request is scoped to.</param>
    /// <param name="sequence">The decoded sequence, when verification succeeds.</param>
    public static bool TryDecode(
        ReadOnlySpan<byte> signingKey,
        string? cursor,
        Guid expectedVaultId,
        out long sequence)
    {
        RequireKey(signingKey);
        sequence = 0;

        if (string.IsNullOrEmpty(cursor))
        {
            return false;
        }

        if (!Base64Url.TryDecode(cursor, out var buffer) || buffer.Length <= TagLength)
        {
            return false;
        }

        var payload = buffer.AsSpan(0, buffer.Length - TagLength);
        var providedTag = buffer.AsSpan(buffer.Length - TagLength, TagLength);

        Span<byte> expectedTag = stackalloc byte[TagLength];
        ComputeTag(signingKey, payload, expectedTag);

        // Constant-time: a timing oracle here would let an attacker forge a tag byte by byte.
        if (!CryptographicOperations.FixedTimeEquals(providedTag, expectedTag))
        {
            return false;
        }

        return TryParsePayload(payload, expectedVaultId, out sequence);
    }

    private static string FormatPayload(Guid vaultId, long sequence) =>
        string.Create(
            CultureInfo.InvariantCulture,
            $"{Version}{Separator}{vaultId:D}{Separator}{sequence}");

    private static bool TryParsePayload(ReadOnlySpan<byte> payload, Guid expectedVaultId, out long sequence)
    {
        sequence = 0;

        string text;
        try
        {
            text = Encoding.UTF8.GetString(payload);
        }
        catch (DecoderFallbackException)
        {
            return false;
        }

        // Tagged, so the shape is ours — but parse defensively anyway, because a key rotation
        // could make an old format verify against a new expectation.
        var parts = text.Split(Separator);
        if (parts.Length != 3)
        {
            return false;
        }

        if (!string.Equals(parts[0], Version, StringComparison.Ordinal))
        {
            return false;
        }

        if (!Guid.TryParseExact(parts[1], "D", out var vaultId) || vaultId != expectedVaultId)
        {
            return false;
        }

        return long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence)
            && sequence >= 0;
    }

    private static void ComputeTag(
        ReadOnlySpan<byte> signingKey,
        ReadOnlySpan<byte> payload,
        Span<byte> destination)
    {
        Span<byte> full = stackalloc byte[HMACSHA256.HashSizeInBytes];
        HMACSHA256.HashData(signingKey, payload, full);
        full[..TagLength].CopyTo(destination);
    }

    private static void RequireKey(ReadOnlySpan<byte> signingKey)
    {
        if (signingKey.Length < MinimumKeyLength)
        {
            throw new ArgumentException(
                $"Cursor signing key must be at least {MinimumKeyLength} bytes.",
                nameof(signingKey));
        }
    }
}

/// <summary>Base64url without padding, as used in URLs and headers.</summary>
internal static class Base64Url
{
    internal static string Encode(ReadOnlySpan<byte> value) =>
        Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');

    internal static bool TryDecode(string value, out byte[] result)
    {
        result = [];

        var normalised = value.Replace('-', '+').Replace('_', '/');
        var padding = (4 - (normalised.Length % 4)) % 4;
        if (padding == 3)
        {
            // A length of 4n+1 cannot be valid base64.
            return false;
        }

        try
        {
            result = Convert.FromBase64String(normalised + new string('=', padding));
            return true;
        }
        catch (FormatException)
        {
            return false;
        }
    }
}
