Code/Tikfinity/Protocol/TikfinityEventParser.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;

namespace TikfinitySbox;

/// <summary>
/// Normalizes several common TikFinity envelope shapes without trusting dynamic objects.
/// </summary>
public static class TikfinityEventParser
{
    public const int MaxIdentityLength = 96;
    public const int MaxDisplayLength = 128;
    public const int MaxCommentLength = 512;
    public const int MaxUrlLength = 1024;
    public const int MaxCount = 1_000_000;

    static string[] TypeNames => new[] { "eventType", "event_type", "event", "type" };

    public static bool TryParseMany( string json, List<TikfinityEvent> output, out string error )
    {
        error = "";
        if ( output is null )
        {
            error = "Output list is null.";
            return false;
        }

        if ( string.IsNullOrWhiteSpace( json ) )
        {
            error = "Message is empty.";
            return false;
        }

        var initialCount = output.Count;

        try
        {
            using var document = JsonDocument.Parse( json );
            var root = document.RootElement;

            if ( root.ValueKind == JsonValueKind.Array )
            {
                foreach ( var item in root.EnumerateArray() )
                    TryParseObject( item, default, false, output );
            }
            else if ( root.ValueKind == JsonValueKind.Object )
            {
                if ( TryGetProperty( root, "data", out var data ) && data.ValueKind == JsonValueKind.Array )
                {
                    foreach ( var item in data.EnumerateArray() )
                        TryParseObject( item, root, true, output );
                }
                else
                {
                    TryParseObject( root, default, false, output );
                }
            }
            else
            {
                error = "Root JSON value must be an object or array.";
                return false;
            }

            if ( output.Count == initialCount )
            {
                error = "No event objects were found.";
                return false;
            }

            return true;
        }
        catch ( JsonException ex )
        {
            error = Limit( ex.Message, MaxDisplayLength );
            return false;
        }
        catch ( Exception ex )
        {
            error = Limit( ex.Message, MaxDisplayLength );
            return false;
        }
    }

    static void TryParseObject( JsonElement item, JsonElement parent, bool hasParent, List<TikfinityEvent> output )
    {
        if ( item.ValueKind != JsonValueKind.Object ) return;

        var scopes = new List<JsonElement> { item };
        AddObjectScope( item, "data", scopes );
        AddObjectScope( item, "payload", scopes );
        AddObjectScope( item, "eventData", scopes );

        if ( hasParent )
        {
            scopes.Add( parent );
            AddObjectScope( parent, "payload", scopes );
        }

        var type = ReadString( scopes, TypeNames, MaxIdentityLength );
        var kind = ParseKind( type );

        var userScopes = new List<JsonElement>( scopes );
        AddNestedObjectScopes( scopes, "user", userScopes );
        AddNestedObjectScopes( scopes, "sender", userScopes );

        var giftScopes = new List<JsonElement>( scopes );
        AddNestedObjectScopes( scopes, "gift", giftScopes );
        AddNestedObjectScopes( scopes, "extendedGiftInfo", giftScopes );

        var repeatCount = ClampCount( ReadInt( scopes, new[] { "repeatCount", "repeat_count", "repeat", "count" }, 1 ) );
        if ( repeatCount < 1 ) repeatCount = 1;

        var giftCoins = ClampCount( ReadInt( giftScopes, new[] { "diamondCount", "diamond_count", "coins", "coinValue", "value" }, 0 ) );
        var totalCoins = ClampCount( ReadInt( scopes, new[] { "totalDiamonds", "total_diamonds", "totalCoins", "total_coins", "diamondCost" }, 0 ) );
        if ( totalCoins <= 0 && giftCoins > 0 )
            totalCoins = ClampLongToCount( (long)giftCoins * repeatCount );

        var hasRepeatEnd = TryReadBool( scopes, new[] { "repeatEnd", "repeat_end", "isFinal", "streakEnded" }, out var repeatEnd );
        var giftType = ReadInt( giftScopes, new[] { "giftType", "gift_type", "type" }, 0 );
        var isStreakable = ReadBool( giftScopes, new[] { "streakable", "isStreakable" }, giftType == 1 );

        output.Add( new TikfinityEvent
        {
            Kind = kind,
            RawType = Limit( type, MaxIdentityLength ),
            EventId = ReadString( scopes, new[] { "eventId", "event_id", "msgId", "messageId", "id" }, MaxIdentityLength ),
            TimestampMilliseconds = ReadLong( scopes, new[] { "timestamp", "timestampMs", "createTime", "createdAt" }, 0L ),

            UserId = ReadString( userScopes, new[] { "userId", "user_id", "id" }, MaxIdentityLength ),
            Username = ReadString( userScopes, new[] { "uniqueId", "unique_id", "username", "userName" }, MaxIdentityLength ),
            Nickname = ReadString( userScopes, new[] { "nickname", "displayName", "display_name" }, MaxDisplayLength ),
            ProfilePictureUrl = ReadString( userScopes, new[] { "profilePictureUrl", "profilePicture", "profile_picture_url", "avatarUrl" }, MaxUrlLength ),

            GiftId = ReadString( giftScopes, new[] { "giftId", "gift_id", "id" }, MaxIdentityLength ),
            GiftName = ReadString( giftScopes, new[] { "giftName", "gift_name", "name" }, MaxDisplayLength ),
            GiftCoins = giftCoins,
            TotalCoins = totalCoins,
            RepeatCount = repeatCount,
            IsStreakable = isStreakable,
            HasRepeatEnd = hasRepeatEnd,
            RepeatEnd = repeatEnd,

            Comment = ReadString( scopes, new[] { "comment", "commentText", "comment_text", "text", "message" }, MaxCommentLength ),
            LikeCount = ClampCount( ReadInt( scopes, new[] { "likeCount", "like_count", "likes", "count" }, 0 ) ),
            ViewerCount = ClampCount( ReadInt( scopes, new[] { "viewerCount", "viewer_count", "viewers", "count" }, 0 ) )
        } );
    }

    static TikfinityEventKind ParseKind( string type )
    {
        var normalized = (type ?? "").Trim().ToLowerInvariant();
        return normalized switch
        {
            "gift" or "gifts" => TikfinityEventKind.Gift,
            "chat" or "comment" or "comments" => TikfinityEventKind.Comment,
            "like" or "likes" => TikfinityEventKind.Like,
            "follow" or "follower" => TikfinityEventKind.Follow,
            "share" => TikfinityEventKind.Share,
            "subscribe" or "subscription" or "sub" => TikfinityEventKind.Subscribe,
            "join" or "member" => TikfinityEventKind.Join,
            "viewer" or "viewer_update" or "roomuser" => TikfinityEventKind.ViewerUpdate,
            "streamend" or "stream_end" or "liveend" => TikfinityEventKind.StreamEnd,
            _ => TikfinityEventKind.Unknown
        };
    }

    static void AddObjectScope( JsonElement source, string name, List<JsonElement> target )
    {
        if ( TryGetProperty( source, name, out var value ) && value.ValueKind == JsonValueKind.Object )
            target.Add( value );
    }

    static void AddNestedObjectScopes( List<JsonElement> sources, string name, List<JsonElement> target )
    {
        var count = sources.Count;
        for ( var i = 0; i < count; i++ )
        {
            if ( TryGetProperty( sources[i], name, out var value ) && value.ValueKind == JsonValueKind.Object )
                target.Insert( 0, value );
        }
    }

    static bool TryGetProperty( JsonElement source, string name, out JsonElement value )
    {
        value = default;
        if ( source.ValueKind != JsonValueKind.Object ) return false;
        if ( source.TryGetProperty( name, out value ) ) return true;

        foreach ( var property in source.EnumerateObject() )
        {
            if ( string.Equals( property.Name, name, StringComparison.OrdinalIgnoreCase ) )
            {
                value = property.Value;
                return true;
            }
        }

        return false;
    }

    static bool TryFind( List<JsonElement> scopes, string[] names, out JsonElement value )
    {
        foreach ( var scope in scopes )
        {
            foreach ( var name in names )
            {
                if ( TryGetProperty( scope, name, out value ) ) return true;
            }
        }

        value = default;
        return false;
    }

    static string ReadString( List<JsonElement> scopes, string[] names, int maxLength )
    {
        if ( !TryFind( scopes, names, out var value ) ) return "";

        string result;
        switch ( value.ValueKind )
        {
            case JsonValueKind.String:
                result = value.GetString() ?? "";
                break;
            case JsonValueKind.Number:
            case JsonValueKind.True:
            case JsonValueKind.False:
                result = value.ToString();
                break;
            default:
                return "";
        }

        return Limit( result.Trim(), maxLength );
    }

    static int ReadInt( List<JsonElement> scopes, string[] names, int fallback )
    {
        if ( !TryFind( scopes, names, out var value ) ) return fallback;
        if ( value.ValueKind == JsonValueKind.Number && value.TryGetInt32( out var number ) ) return number;
        if ( value.ValueKind == JsonValueKind.String && int.TryParse( value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number ) ) return number;
        return fallback;
    }

    static long ReadLong( List<JsonElement> scopes, string[] names, long fallback )
    {
        if ( !TryFind( scopes, names, out var value ) ) return fallback;
        if ( value.ValueKind == JsonValueKind.Number && value.TryGetInt64( out var number ) ) return number;
        if ( value.ValueKind == JsonValueKind.String && long.TryParse( value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number ) ) return number;
        return fallback;
    }

    static bool ReadBool( List<JsonElement> scopes, string[] names, bool fallback )
        => TryReadBool( scopes, names, out var value ) ? value : fallback;

    static bool TryReadBool( List<JsonElement> scopes, string[] names, out bool result )
    {
        result = false;
        if ( !TryFind( scopes, names, out var value ) ) return false;

        if ( value.ValueKind == JsonValueKind.True )
        {
            result = true;
            return true;
        }

        if ( value.ValueKind == JsonValueKind.False )
        {
            result = false;
            return true;
        }

        if ( value.ValueKind == JsonValueKind.Number && value.TryGetInt32( out var number ) )
        {
            result = number != 0;
            return true;
        }

        if ( value.ValueKind == JsonValueKind.String )
        {
            var text = value.GetString();
            if ( bool.TryParse( text, out result ) ) return true;
            if ( int.TryParse( text, out number ) )
            {
                result = number != 0;
                return true;
            }
        }

        return false;
    }

    static int ClampCount( int value ) => value < 0 ? 0 : value > MaxCount ? MaxCount : value;
    static int ClampLongToCount( long value ) => value < 0L ? 0 : value > MaxCount ? MaxCount : (int)value;

    static string Limit( string value, int maxLength )
    {
        if ( string.IsNullOrEmpty( value ) ) return "";
        if ( maxLength < 1 ) return "";
        return value.Length <= maxLength ? value : value.Substring( 0, maxLength );
    }
}