//
//  RNUtils.m
//  RNInsider
//
//  Created by Heysem on 22.12.2025.
//  Copyright © 2025 Insider. All rights reserved.
//

#import "RNUtils.h"

#import <errno.h>
#import <stdlib.h>

// JavaScript's own Date range, mirroring the WebView bridge and RNUtils.java. Beyond it the core's
// formatter renders an empty string, so the entry is dropped.
static const double RNInsiderMaxDateMillis = 8640000000000000.0;

static BOOL RNInsiderIsSendableEpochMillis(double epochMillis) {
    return isfinite(epochMillis) && fabs(epochMillis) <= RNInsiderMaxDateMillis;
}

// A known type whose value has the wrong class fails its own isKindOfClass guard and reaches this
// branch, so the reason has to name the mismatch rather than the type - as Android does. The
// vocabulary belongs to the caller: each loop knows a different set, and a type one loop never
// handles is genuinely unknown there rather than mistyped.
static NSString *RNInsiderSkipReasonForType(NSString *type, NSArray<NSString *> *knownTypes) {
    return [knownTypes containsObject:type ?: @""]
        ? @"Parameter value does not match its declared type."
        : @"Unknown parameter type received.";
}

// A fractional value tagged `integer` comes from a hand-built payload; truncating it would send a
// number the partner never wrote. NaN and infinity are not whole either, so they go the same way.
static BOOL RNInsiderIsWholeNumber(NSNumber *number) {
    double value = number.doubleValue;
    return isfinite(value) && value == rint(value);
}

// -longLongValue cannot tell @"abc" or @"" from @"0" - all three fabricate a 1970 date - and
// saturates an over-long input, so parse explicitly and reject anything incomplete.
static BOOL RNInsiderEpochMillisFromString(NSString *string, long long *outEpochMillis) {
    if (![string isKindOfClass:[NSString class]]) return NO;

    const char *utf8 = string.UTF8String;
    if (utf8 == NULL || *utf8 == '\0') return NO;

    errno = 0;
    char *end = NULL;
    long long parsed = strtoll(utf8, &end, 10);
    if (end == utf8 || *end != '\0' || errno == ERANGE) return NO;
    if (!RNInsiderIsSendableEpochMillis((double)parsed)) return NO;

    *outEpochMillis = parsed;
    return YES;
}

// The core never drops an event because one parameter is bad, so neither do these loops - nothing
// reaches the call-level @catch, which would lose the whole call. The failure is still reported.
static void RNInsiderReportSkippedParameter(NSException *exception, const char *function, int line) {
    [Insider sendError:exception desc:[NSString stringWithFormat:@"%s:%d", function, line]];
}

@implementation RNUtils

+ (InsiderEvent *)parseEventFromEventName:(NSString *)eventName
                            andParameters:(NSArray *)parameters {

    InsiderEvent *event = [Insider tagEvent:eventName];

    if (![parameters isKindOfClass:[NSArray class]]) {
        return event;
    }

    for (id item in parameters) {
        // Per-parameter containment: a raising setter or an unknown type costs only this
        // parameter, never the event.
        @try {
            if (![item isKindOfClass:[NSDictionary class]]) continue;

            NSDictionary *parameter = (NSDictionary *)item;

            NSString *type = parameter[@"type"];
            NSString *key  = parameter[@"key"];
            if (![type isKindOfClass:[NSString class]] || ![key isKindOfClass:[NSString class]]) continue;

            id value = parameter[@"value"];

            if ([type isEqualToString:@"string"] && [value isKindOfClass:[NSString class]]) {
                NSString *stringValue = value;
                event.addParameterWithString(key, stringValue);
            }
            else if ([type isEqualToString:@"integer"] && [value isKindOfClass:[NSNumber class]]) {
                NSNumber *numberValue = value;
                if (RNInsiderIsWholeNumber(numberValue)) {
                    int integerValue = (int)numberValue.integerValue;
                    event.addParameterWithInt(key, integerValue);
                }
            }
            else if ([type isEqualToString:@"double"] && [value isKindOfClass:[NSNumber class]]) {
                NSNumber *numberValue = value;
                double doubleValue = (numberValue != nil ? numberValue.doubleValue : 0.0);
                event.addParameterWithDouble(key, doubleValue);
            }
            else if ([type isEqualToString:@"boolean"] && [value isKindOfClass:[NSNumber class]]) {
                NSNumber *numberValue = value;
                BOOL booleanValue = (numberValue != nil ? numberValue.boolValue : NO);
                event.addParameterWithBoolean(key, booleanValue);
            }
            else if ([type isEqualToString:@"date"] && [value isKindOfClass:[NSString class]]) {
                long long epochMillis = 0;
                if (RNInsiderEpochMillisFromString((NSString *)value, &epochMillis)) {
                    NSTimeInterval seconds = ((NSTimeInterval)epochMillis) / 1000.0;
                    NSDate *dateValue = [NSDate dateWithTimeIntervalSince1970:seconds];
                    event.addParameterWithDate(key, dateValue);
                }
            }
            else if ([type isEqualToString:@"strings"] && [value isKindOfClass:[NSArray class]]) {
                NSArray * stringsValue = value;
                event.addParameterWithStringArray(key, stringsValue);
            }
            else if ([type isEqualToString:@"numbers"] && [value isKindOfClass:[NSArray class]]) {
                NSArray * numbersValue = value;
                event.addParameterWithNumericArray(key, numbersValue);
            }
            else if ([type isEqualToString:@"objects"] && [value isKindOfClass:[NSArray class]]) {
                event.addParameterWithObjectArray(key, [self parseCustomParametersList:value]);
            }
            else if ([type isEqualToString:@"objects"]) {
                // Android skips this parameter rather than forwarding an empty list, so a hand-built
                // payload produces the same event on both platforms.
                RNInsiderReportSkippedParameter([NSException exceptionWithName:NSInvalidArgumentException
                                                                        reason:@"Objects parameter value is not an array."
                                                                      userInfo:@{ @"key": key ?: @"(null)" }],
                                                __func__, __LINE__);
            }
            else {
                // Reported and skipped rather than thrown: an unknown type is one parameter's
                // problem, and throwing into the handler below would only obscure that.
                RNInsiderReportSkippedParameter([NSException exceptionWithName:NSInvalidArgumentException
                                                                        reason:RNInsiderSkipReasonForType(type, @[@"string", @"integer", @"double", @"boolean", @"date", @"strings", @"numbers", @"objects"])
                                                                      userInfo:@{ @"type": type ?: @"(null)" }],
                                                __func__, __LINE__);
            }
        } @catch (NSException *exception) {
            RNInsiderReportSkippedParameter(exception, __func__, __LINE__);
        }
    }

    return event;
}

+ (nonnull InsiderProduct *)parseProductFromRequiredFields:(nonnull NSDictionary *)requiredFields
                                         andOptionalFields:(nullable NSDictionary *)optionalFields
                                       andCustomParameters:(nullable NSArray *)customParameters {

    /// Apply required fields
    NSString *productId = requiredFields[@"product_id"];
    NSString *name = requiredFields[@"name"];
    NSArray *taxonomy = requiredFields[@"taxonomy"];
    NSString *imageURL = requiredFields[@"image_url"];
    NSNumber *priceNumber = requiredFields[@"price"];
    double price = [priceNumber isKindOfClass:[NSNumber class]] ? priceNumber.doubleValue : 0.0;
    NSString *currency = requiredFields[@"currency"];

    InsiderProduct *product = [Insider createNewProductWithID:productId name:name taxonomy:taxonomy imageURL:imageURL price:price currency:currency];

    /// Apply optional fields
    for (NSString *key in optionalFields) {
        // Per-parameter containment. Every branch below casts the bridge value
        // without checking it, so one mistyped optional field would cost the whole product.
        @try {
            id value = optionalFields[key];

            if ([key isEqualToString:@"color"]) {
                NSString *color = (NSString *)value;
                product.setColor(color);
            }
            else if ([key isEqualToString:@"voucher_name"]) {
                NSString *voucherName = (NSString *)value;
                product.setVoucherName(voucherName);
            }
            else if ([key isEqualToString:@"promotion_name"]) {
                NSString *promotionName = (NSString *)value;
                product.setPromotionName(promotionName);
            }
            else if ([key isEqualToString:@"size"]) {
                NSString *size = (NSString *)value;
                product.setSize(size);
            }
            else if ([key isEqualToString:@"sale_price"]) {
                NSNumber *salePriceNumber = (NSNumber *)value;
                double salePrice = salePriceNumber.doubleValue;
                product.setSalePrice(salePrice);
            }
            else if ([key isEqualToString:@"shipping_cost"]) {
                NSNumber *shippingCostNumber = (NSNumber *)value;
                double shippingCost = shippingCostNumber.doubleValue;
                product.setShippingCost(shippingCost);
            }
            else if ([key isEqualToString:@"voucher_discount"]) {
                NSNumber *voucherDiscountNumber = (NSNumber *)value;
                double voucherDiscount = voucherDiscountNumber.doubleValue;
                product.setVoucherDiscount(voucherDiscount);
            }
            else if ([key isEqualToString:@"promotion_discount"]) {
                NSNumber *promotionDiscountNumber = (NSNumber *)value;
                double promotionDiscount = promotionDiscountNumber.doubleValue;
                product.setPromotionDiscount(promotionDiscount);
            }
            else if ([key isEqualToString:@"stock"]) {
                NSNumber *stockNumber = (NSNumber *)value;
                int stock = stockNumber.intValue;
                product.setStock(stock);
            }
            else if ([key isEqualToString:@"quantity"]) {
                NSNumber *quantityNumber = (NSNumber *)value;
                int quantity = quantityNumber.intValue;
                product.setQuantity(quantity);
            }
            else if ([key isEqualToString:@"group_code"]) {
                NSString *groupCode = (NSString *)value;
                product.setGroupCode(groupCode);
            }
            else if ([key isEqualToString:@"brand"]) {
                NSString *brand = (NSString *)value;
                product.setBrand(brand);
            }
            else if ([key isEqualToString:@"sku"]) {
                NSString *sku = (NSString *)value;
                product.setSku(sku);
            }
            else if ([key isEqualToString:@"gender"]) {
                NSString *gender = (NSString *)value;
                product.setGender(gender);
            }
            else if ([key isEqualToString:@"multipack"]) {
                NSString *multipack = (NSString *)value;
                product.setMultipack(multipack);
            }
            else if ([key isEqualToString:@"product_type"]) {
                NSString *productType = (NSString *)value;
                product.setProductType(productType);
            }
            else if ([key isEqualToString:@"gtin"]) {
                NSString *gtin = (NSString *)value;
                product.setGtin(gtin);
            }
            else if ([key isEqualToString:@"description"]) {
                NSString *desc = (NSString *)value;
                product.setDescription(desc);
            }
            else if ([key isEqualToString:@"tags"]) {
                NSArray *tagsArray = (NSArray *)value;
                product.setTags(tagsArray);
            }
            else if ([key isEqualToString:@"in_stock"]) {
                NSNumber *inStockNumber = (NSNumber *)value;
                BOOL inStock = inStockNumber.boolValue;
                product.setInStock(inStock);
            }
            else if ([key isEqualToString:@"product_url"]) {
                NSString *productURL = (NSString *)value;
                product.setProductURL(productURL);
            }
        } @catch (NSException *exception) {
            RNInsiderReportSkippedParameter(exception, __func__, __LINE__);
        }
    }

    /// Apply custom parameters
    for (id item in customParameters) {
        // Per-parameter containment, like the two loops above.
        @try {
            if (![item isKindOfClass:[NSDictionary class]]) continue;

            NSDictionary *parameter = (NSDictionary *)item;

            NSString *type = parameter[@"type"];
            NSString *key  = parameter[@"key"];
            if (![type isKindOfClass:[NSString class]] || ![key isKindOfClass:[NSString class]]) continue;

            id value = parameter[@"value"];

            if ([type isEqualToString:@"string"] && [value isKindOfClass:[NSString class]]) {
                NSString *stringValue = (NSString *)value;
                product.setCustomAttributeWithString(key, stringValue);
            }
            else if ([type isEqualToString:@"integer"] && [value isKindOfClass:[NSNumber class]]) {
                NSNumber *numberValue = (NSNumber *)value;
                if (RNInsiderIsWholeNumber(numberValue)) {
                    int intValue = (int)numberValue.integerValue;
                    product.setCustomAttributeWithInt(key, intValue);
                }
            }
            else if ([type isEqualToString:@"double"] && [value isKindOfClass:[NSNumber class]]) {
                NSNumber *numberValue = (NSNumber *)value;
                double doubleValue = numberValue.doubleValue;
                product.setCustomAttributeWithDouble(key, doubleValue);
            }
            else if ([type isEqualToString:@"boolean"] && [value isKindOfClass:[NSNumber class]]) {
                NSNumber *numberValue = (NSNumber *)value;
                BOOL booleanValue = numberValue.boolValue;
                product.setCustomAttributeWithBoolean(key, booleanValue);
            }
            else if ([type isEqualToString:@"date"] && [value isKindOfClass:[NSString class]]) {
                // Epoch millis passed as string from RN
                long long epochMillis = 0;
                if (RNInsiderEpochMillisFromString((NSString *)value, &epochMillis)) {
                    NSTimeInterval seconds = ((NSTimeInterval)epochMillis) / 1000.0;
                    NSDate *dateValue = [NSDate dateWithTimeIntervalSince1970:seconds];
                    product.setCustomAttributeWithDate(key, dateValue);
                }
            }
            else if ([type isEqualToString:@"strings"] && [value isKindOfClass:[NSArray class]]) {
                NSArray *stringsValue = (NSArray *)value;
                product.setCustomAttributeWithStringArray(key, stringsValue);
            }
            else if ([type isEqualToString:@"numbers"] && [value isKindOfClass:[NSArray class]]) {
                NSArray *numbersValue = (NSArray *)value;
                product.setCustomAttributeWithNumericArray(key, numbersValue);
            }
            else {
                // Reported and skipped, like parseEventFromEventName above.
                RNInsiderReportSkippedParameter([NSException exceptionWithName:NSInvalidArgumentException
                                                                        reason:RNInsiderSkipReasonForType(type, @[@"string", @"integer", @"double", @"boolean", @"date", @"strings", @"numbers"])
                                                                      userInfo:@{
                                                                         @"type": type ?: @"(null)",
                                                                         @"key": key ?: @"(null)"
                                                                      }],
                                                __func__, __LINE__);
            }
        } @catch (NSException *exception) {
            RNInsiderReportSkippedParameter(exception, __func__, __LINE__);
        }
    }

    return product;
}

+ (nonnull NSDictionary<NSString *, id> *)parseCustomParameters:(nonnull NSArray *)customParameters {
    NSMutableDictionary<NSString *, id> *mappedCustomParameters = [NSMutableDictionary dictionary];

    for (id item in customParameters) {
        if (![item isKindOfClass:[NSDictionary class]]) continue;

        NSDictionary *parameter = (NSDictionary *)item;
        NSString *type = parameter[@"type"];
        NSString *key  = parameter[@"key"];

        if (![type isKindOfClass:[NSString class]] || ![key isKindOfClass:[NSString class]]) continue;

        id value = parameter[@"value"];

        if ([type isEqualToString:@"string"] && [value isKindOfClass:[NSString class]]) {
            NSString *stringValue = (NSString *)value;
            mappedCustomParameters[key] = stringValue;
        }
        else if ([type isEqualToString:@"integer"] && [value isKindOfClass:[NSNumber class]]) {
            // Split out of the shared numeric branch below so the wholeness guard applies to the
            // `integer` tag only: `double` and `boolean` keep crossing untouched.
            NSNumber *numberValue = (NSNumber *)value;
            if (RNInsiderIsWholeNumber(numberValue)) {
                // Narrowed so both platforms send the same shape, but only on an exact round trip.
                // 2^63 is excluded before the cast: -longLongValue saturates, and LLONG_MAX rounds back.
                double asDouble = numberValue.doubleValue;
                if (asDouble >= 9223372036854775808.0 || asDouble < -9223372036854775808.0) {
                    mappedCustomParameters[key] = numberValue;
                } else {
                    long long asLongLong = numberValue.longLongValue;
                    mappedCustomParameters[key] = (double)asLongLong == asDouble ? @(asLongLong) : numberValue;
                }
            }
        }
        else if (([type isEqualToString:@"double"] || [type isEqualToString:@"boolean"]) && [value isKindOfClass:[NSNumber class]]) {
            NSNumber *numberValue = (NSNumber *)value;
            mappedCustomParameters[key] = numberValue;
        }
        else if ([type isEqualToString:@"date"] && [value isKindOfClass:[NSNumber class]]) {
            NSNumber *numberValue = (NSNumber *)value;
            // -longLongValue turns NaN into 0 (1 January 1970) and an infinite value into LLONG_MAX
            // (a date the core cannot even format), so check finiteness and range first.
            double epochMillis = numberValue.doubleValue;
            if (RNInsiderIsSendableEpochMillis(epochMillis)) {
                // Convert from the same double the guard checked - as InsiderObjectEntries.swift
                // does - rather than re-reading -longLongValue, which is the accessor that
                // saturates and truncates in the first place.
                NSTimeInterval seconds = ((NSTimeInterval)epochMillis) / 1000.0;
                NSDate *dateValue = [NSDate dateWithTimeIntervalSince1970:seconds];
                mappedCustomParameters[key] = dateValue;
            }
        }
        else if ([type isEqualToString:@"object"] && [value isKindOfClass:[NSArray class]]) {
            mappedCustomParameters[key] = [self parseCustomParameters:(NSArray *)value];
        }
        else if (([type isEqualToString:@"numeric_array"] || [type isEqualToString:@"string_array"]) && [value isKindOfClass:[NSArray class]]) {
            NSArray *arrayValue = (NSArray *)value;
            mappedCustomParameters[key] = arrayValue;
        }
    }

    return mappedCustomParameters;
}

+ (nonnull NSArray<NSDictionary<NSString *, id> *> *)parseCustomParametersList:(nullable NSArray *)list {
    NSMutableArray<NSDictionary<NSString *, id> *> *objects = [NSMutableArray array];

    if (![list isKindOfClass:[NSArray class]]) {
        return objects;
    }

    for (id item in list) {
        if (![item isKindOfClass:[NSArray class]]) continue;

        [objects addObject:[self parseCustomParameters:(NSArray *)item]];
    }

    return objects;
}

/**
 * Normalizes a recorded-event `parameters` dictionary into values React Native's bridge
 * can actually serialize, mirroring Android's `RNUtils.convertObjectMapToWritableMap`.
 *
 * WHY THIS IS NOT OPTIONAL: RN converts an Obj-C payload with
 * `convertIdToFollyDynamic` (ReactCommon/react/utils/platform/ios/react/utils/FollyConvert.mm).
 * That function dispatches ONLY on NSNumber / NSString / NSArray / NSDictionary / nil /
 * NSNull. Anything else — NSDate above all, but also NSURL, NSData, NSUUID — falls off
 * the end of the chain and hits `return nil`, producing a NULL folly::dynamic. The key
 * survives but its value silently arrives in JS as `null`, so a `Date` parameter that
 * Android delivers as epoch-millis would reach an iOS integrator as nothing at all.
 *
 * Conversions applied, recursively through nested dictionaries and arrays:
 *   - NSDate   -> epoch MILLISECONDS as an NSNumber, matching Android's
 *                 `map.putDouble(key, ((Date) value).getTime())` and the repo's
 *                 epoch-millis bridge convention for dates.
 *   - NSNull   -> preserved (RN maps it to JS `null`).
 *   - Anything else non-serializable -> its `description`, so ONE odd value degrades to a
 *                 readable string instead of vanishing.
 *
 * NOTE: this is only about `Date`s nested INSIDE `parameters`. The top-level `timestamp`
 * of a recorded event stays in WHOLE SECONDS by explicit product decision — see
 * `RNInsiderEventListener.onEventRecorded` in RNInsider.m.
 */
+ (NSDictionary<NSString *, id> *)convertParametersForBridge:(NSDictionary *)parameters {
    if (![parameters isKindOfClass:[NSDictionary class]]) {
        return @{};
    }

    NSMutableDictionary<NSString *, id> *converted = [NSMutableDictionary dictionaryWithCapacity:parameters.count];

    for (id key in parameters) {
        // The bridge can only key an object by a string.
        NSString *stringKey = [key isKindOfClass:[NSString class]] ? (NSString *)key : [key description];
        if (stringKey == nil) continue;

        converted[stringKey] = [self convertValueForBridge:parameters[key]];
    }

    return converted;
}

/// Recursive leaf/branch conversion backing `convertParametersForBridge:`.
+ (id)convertValueForBridge:(id)value {
    if (value == nil || value == [NSNull null]) {
        return [NSNull null];
    }

    if ([value isKindOfClass:[NSDictionary class]]) {
        return [self convertParametersForBridge:(NSDictionary *)value];
    }

    if ([value isKindOfClass:[NSArray class]]) {
        NSArray *array = (NSArray *)value;
        NSMutableArray *convertedArray = [NSMutableArray arrayWithCapacity:array.count];

        for (id element in array) {
            [convertedArray addObject:[self convertValueForBridge:element]];
        }

        return convertedArray;
    }

    // NSDate -> epoch millis. Checked before the NSNumber/NSString passthrough because a
    // date is the value type that actually motivated this converter.
    if ([value isKindOfClass:[NSDate class]]) {
        NSTimeInterval seconds = [(NSDate *)value timeIntervalSince1970];
        return @((long long)llround(seconds * 1000.0));
    }

    // NSNumber (incl. booleans) and NSString cross the bridge untouched.
    if ([value isKindOfClass:[NSNumber class]] || [value isKindOfClass:[NSString class]]) {
        return value;
    }

    // Unknown leaf: degrade to a readable string rather than letting RN null it out.
    return [value description] ?: [NSNull null];
}

+ (NSString *)mapAppCardsErrorCode:(NSError *)error {
    if ([error.domain isEqualToString:InsiderAppCardsErrorDomain]) {
        switch (error.code) {
            case InsiderAppCardsErrorCodeSdkNotInitialized: return @"sdkNotInitialized";
            case InsiderAppCardsErrorCodeInvalidParameter: return @"invalidParameter";
            case InsiderAppCardsErrorCodeNetworkError: return @"networkError";
            case InsiderAppCardsErrorCodeServerError: return @"serverError";
            case InsiderAppCardsErrorCodeParseError: return @"parseError";
            default: return @"unknown";
        }
    }
    return @"unknown";
}

+ (NSDictionary *)appCardsErrorToDictionary:(NSError *)error {
    NSString *code = [self mapAppCardsErrorCode:error];
    NSString *message = error.localizedDescription ?: @"An unexpected error occurred.";
    return @{@"code": code, @"message": message};
}

+ (InsiderGender)genderFromInt:(int)gender {
    switch (gender) {
        case 0:
            return InsiderGenderMale;
        case 1:
            return InsiderGenderFemale;
        default:
            return InsiderGenderOther;
    }
}

+ (ContentOptimizerDataType)contentDataTypeFromInt:(int)dataType isValid:(BOOL *)isValid {
    switch (dataType) {
        case 0:
            *isValid = YES;
            return ContentOptimizerDataTypeContent;
        case 1:
            *isValid = YES;
            return ContentOptimizerDataTypeElement;
        default:
            *isValid = NO;
            return ContentOptimizerDataTypeContent;
    }
}

@end
