/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.apache.hudi.util;

import org.apache.hudi.avro.model.HoodieMetadataRecord;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchemaField;
import org.apache.hudi.common.schema.HoodieSchemaType;
import org.apache.hudi.metadata.HoodieMetadataPayload;

import org.apache.flink.table.api.DataTypes;
import org.apache.flink.table.types.DataType;
import org.apache.flink.table.types.logical.ArrayType;
import org.apache.flink.table.types.logical.DecimalType;
import org.apache.flink.table.types.logical.LogicalType;
import org.apache.flink.table.types.logical.LogicalTypeRoot;
import org.apache.flink.table.types.logical.MapType;
import org.apache.flink.table.types.logical.RowType;
import org.apache.flink.table.types.logical.TimestampType;
import org.apache.flink.table.types.logical.VarCharType;
import org.apache.flink.table.types.logical.VarBinaryType;
import org.junit.jupiter.api.Test;

import java.util.Arrays;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
 * Tests for {@link HoodieSchemaConverter}.
 */
public class TestHoodieSchemaConverter {

  @Test
  public void testPrimitiveTypes() {
    // String
    HoodieSchema stringSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.STRING().notNull().getLogicalType());
    assertEquals(HoodieSchemaType.STRING, stringSchema.getType());

    // Int
    HoodieSchema intSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.INT().notNull().getLogicalType());
    assertEquals(HoodieSchemaType.INT, intSchema.getType());

    // Long
    HoodieSchema longSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.BIGINT().notNull().getLogicalType());
    assertEquals(HoodieSchemaType.LONG, longSchema.getType());

    // Float
    HoodieSchema floatSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.FLOAT().notNull().getLogicalType());
    assertEquals(HoodieSchemaType.FLOAT, floatSchema.getType());

    // Double
    HoodieSchema doubleSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.DOUBLE().notNull().getLogicalType());
    assertEquals(HoodieSchemaType.DOUBLE, doubleSchema.getType());

    // Boolean
    HoodieSchema boolSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.BOOLEAN().notNull().getLogicalType());
    assertEquals(HoodieSchemaType.BOOLEAN, boolSchema.getType());

    // Bytes
    HoodieSchema bytesSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.BYTES().notNull().getLogicalType());
    assertEquals(HoodieSchemaType.BYTES, bytesSchema.getType());
  }

  @Test
  public void testNullableTypes() {
    HoodieSchema nullableString = HoodieSchemaConverter.convertToSchema(
        DataTypes.STRING().nullable().getLogicalType());
    assertEquals(HoodieSchemaType.UNION, nullableString.getType());
    assertTrue(nullableString.isNullable());

    HoodieSchema nullableInt = HoodieSchemaConverter.convertToSchema(
        DataTypes.INT().nullable().getLogicalType());
    assertEquals(HoodieSchemaType.UNION, nullableInt.getType());
    assertTrue(nullableInt.isNullable());
  }

  @Test
  public void testTemporalTypes() {
    // Date
    HoodieSchema dateSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.DATE().notNull().getLogicalType());
    assertEquals(HoodieSchemaType.DATE, dateSchema.getType());

    // Time
    HoodieSchema timeSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.TIME(3).notNull().getLogicalType());
    assertEquals(HoodieSchemaType.TIME, timeSchema.getType());
    assertEquals(HoodieSchema.TimePrecision.MILLIS, ((HoodieSchema.Time) timeSchema).getPrecision());

    // Time micros
    HoodieSchema timeMicrosSchema = HoodieSchemaConverter.convertToSchema(
            DataTypes.TIME(6).notNull().getLogicalType());
    assertEquals(HoodieSchemaType.TIME, timeMicrosSchema.getType());
    assertEquals(HoodieSchema.TimePrecision.MICROS, ((HoodieSchema.Time) timeMicrosSchema).getPrecision());

    // Timestamp millis
    HoodieSchema timestampMillisSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.TIMESTAMP(3).notNull().getLogicalType());
    assertEquals(HoodieSchemaType.TIMESTAMP, timestampMillisSchema.getType());
    assertTrue(timestampMillisSchema instanceof HoodieSchema.Timestamp);
    assertEquals(HoodieSchema.TimePrecision.MILLIS,
        ((HoodieSchema.Timestamp) timestampMillisSchema).getPrecision());

    // Timestamp micros
    HoodieSchema timestampMicrosSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.TIMESTAMP(6).notNull().getLogicalType());
    assertEquals(HoodieSchemaType.TIMESTAMP, timestampMicrosSchema.getType());
    assertTrue(timestampMicrosSchema instanceof HoodieSchema.Timestamp);
    assertEquals(HoodieSchema.TimePrecision.MICROS,
        ((HoodieSchema.Timestamp) timestampMicrosSchema).getPrecision());

    // Local timestamp millis
    HoodieSchema localTimestampMillisSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3).notNull().getLogicalType());
    assertEquals(HoodieSchemaType.TIMESTAMP, localTimestampMillisSchema.getType());

    // Local timestamp micros
    HoodieSchema localTimestampMicrosSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6).notNull().getLogicalType());
    assertEquals(HoodieSchemaType.TIMESTAMP, localTimestampMicrosSchema.getType());
  }

  @Test
  public void testDecimalType() {
    HoodieSchema decimalSchema = HoodieSchemaConverter.convertToSchema(
        DataTypes.DECIMAL(10, 2).notNull().getLogicalType(), "test");
    assertEquals(HoodieSchemaType.DECIMAL, decimalSchema.getType());
    assertTrue(decimalSchema instanceof HoodieSchema.Decimal);

    HoodieSchema.Decimal decimal = (HoodieSchema.Decimal) decimalSchema;
    assertEquals(10, decimal.getPrecision());
    assertEquals(2, decimal.getScale());
  }

  @Test
  public void testArrayType() {
    LogicalType arrayType = DataTypes.ARRAY(DataTypes.STRING().notNull()).notNull().getLogicalType();
    HoodieSchema arraySchema = HoodieSchemaConverter.convertToSchema(arrayType);

    assertEquals(HoodieSchemaType.ARRAY, arraySchema.getType());
    assertEquals(HoodieSchemaType.STRING, arraySchema.getElementType().getType());
  }

  @Test
  public void testArrayTypeWithNullableElements() {
    LogicalType arrayType = DataTypes.ARRAY(DataTypes.STRING().nullable()).notNull().getLogicalType();
    HoodieSchema arraySchema = HoodieSchemaConverter.convertToSchema(arrayType);

    assertEquals(HoodieSchemaType.ARRAY, arraySchema.getType());

    HoodieSchema elementSchema = arraySchema.getElementType();
    assertEquals(HoodieSchemaType.UNION, elementSchema.getType());
    assertTrue(elementSchema.isNullable());

    HoodieSchema actualElementType = elementSchema.getNonNullType();
    assertEquals(HoodieSchemaType.STRING, actualElementType.getType());
  }

  @Test
  public void testMapType() {
    LogicalType mapType = DataTypes.MAP(
        DataTypes.STRING().notNull(),
        DataTypes.INT().notNull()).notNull().getLogicalType();
    HoodieSchema mapSchema = HoodieSchemaConverter.convertToSchema(mapType);

    assertEquals(HoodieSchemaType.MAP, mapSchema.getType());
    assertEquals(HoodieSchemaType.INT, mapSchema.getValueType().getType());
  }

  @Test
  public void testMapTypeWithNullableValues() {
    LogicalType mapType = DataTypes.MAP(
        DataTypes.STRING().notNull(),
        DataTypes.INT().nullable()).notNull().getLogicalType();
    HoodieSchema mapSchema = HoodieSchemaConverter.convertToSchema(mapType);

    assertEquals(HoodieSchemaType.MAP, mapSchema.getType());

    HoodieSchema valueSchema = mapSchema.getValueType();
    assertEquals(HoodieSchemaType.UNION, valueSchema.getType());
    assertTrue(valueSchema.isNullable());

    HoodieSchema actualValueType = valueSchema.getNonNullType();
    assertEquals(HoodieSchemaType.INT, actualValueType.getType());
  }

  @Test
  public void testRecordType() {
    LogicalType recordType = DataTypes.ROW(
        DataTypes.FIELD("id", DataTypes.INT().notNull()),
        DataTypes.FIELD("name", DataTypes.STRING().notNull()),
        DataTypes.FIELD("age", DataTypes.INT().nullable())
    ).notNull().getLogicalType();

    HoodieSchema recordSchema = HoodieSchemaConverter.convertToSchema(recordType, "Person");

    assertEquals(HoodieSchemaType.RECORD, recordSchema.getType());
    assertEquals(3, recordSchema.getFields().size());
    assertEquals("id", recordSchema.getFields().get(0).name());
    assertEquals("name", recordSchema.getFields().get(1).name());
    assertEquals("age", recordSchema.getFields().get(2).name());

    // Verify nullable field
    assertTrue(recordSchema.getFields().get(2).schema().isNullable());
  }

  @Test
  public void testNestedRecordType() {
    LogicalType nestedRecordType = DataTypes.ROW(
        DataTypes.FIELD("id", DataTypes.INT().notNull()),
        DataTypes.FIELD("address", DataTypes.ROW(
            DataTypes.FIELD("street", DataTypes.STRING().notNull()),
            DataTypes.FIELD("city", DataTypes.STRING().notNull())
        ).notNull())
    ).notNull().getLogicalType();

    HoodieSchema nestedSchema = HoodieSchemaConverter.convertToSchema(nestedRecordType, "User");

    assertEquals(HoodieSchemaType.RECORD, nestedSchema.getType());
    assertEquals(2, nestedSchema.getFields().size());

    HoodieSchema addressSchema = nestedSchema.getFields().get(1).schema();
    assertEquals(HoodieSchemaType.RECORD, addressSchema.getType());
    assertEquals(2, addressSchema.getFields().size());
  }

  @Test
  public void testComplexNestedStructure() {
    LogicalType complexType = DataTypes.ROW(
        DataTypes.FIELD("id", DataTypes.STRING().notNull()),
        DataTypes.FIELD("tags", DataTypes.ARRAY(DataTypes.STRING().notNull()).notNull()),
        DataTypes.FIELD("metadata", DataTypes.MAP(
            DataTypes.STRING().notNull(),
            DataTypes.STRING().notNull()).notNull()),
        DataTypes.FIELD("nested", DataTypes.ROW(
            DataTypes.FIELD("value", DataTypes.DOUBLE().notNull()),
            DataTypes.FIELD("items", DataTypes.ARRAY(DataTypes.INT().notNull()).notNull())
        ).notNull())
    ).notNull().getLogicalType();

    HoodieSchema complexSchema = HoodieSchemaConverter.convertToSchema(complexType, "ComplexRecord");

    assertNotNull(complexSchema);
    assertEquals(HoodieSchemaType.RECORD, complexSchema.getType());
    assertEquals(4, complexSchema.getFields().size());

    // Verify array field
    assertEquals(HoodieSchemaType.ARRAY, complexSchema.getFields().get(1).schema().getType());

    // Verify map field
    assertEquals(HoodieSchemaType.MAP, complexSchema.getFields().get(2).schema().getType());

    // Verify nested record
    HoodieSchema nestedRecord = complexSchema.getFields().get(3).schema();
    assertEquals(HoodieSchemaType.RECORD, nestedRecord.getType());
    assertEquals(2, nestedRecord.getFields().size());
  }

  @Test
  public void testRoundTripConversion() {
    RowType originalRowType = (RowType) DataTypes.ROW(
        DataTypes.FIELD("id", DataTypes.BIGINT().notNull()),
        DataTypes.FIELD("name", DataTypes.STRING().nullable()),
        DataTypes.FIELD("age", DataTypes.INT().notNull())
    ).notNull().getLogicalType();

    HoodieSchema hoodieSchema = HoodieSchemaConverter.convertToSchema(originalRowType, "TestRecord");
    RowType convertedRowType = HoodieSchemaConverter.convertToRowType(hoodieSchema);

    assertEquals(originalRowType, convertedRowType);
  }

  @Test
  public void testConvertPrimitiveTypesToDataType() {
    HoodieSchema hoodieSchema = HoodieSchema.createRecord(
        "test_record",
        null,
        null,
        Arrays.asList(
            HoodieSchemaField.of("string_col", HoodieSchema.create(HoodieSchemaType.STRING)),
            HoodieSchemaField.of("int_col", HoodieSchema.create(HoodieSchemaType.INT)),
            HoodieSchemaField.of("long_col", HoodieSchema.create(HoodieSchemaType.LONG)),
            HoodieSchemaField.of("float_col", HoodieSchema.create(HoodieSchemaType.FLOAT)),
            HoodieSchemaField.of("double_col", HoodieSchema.create(HoodieSchemaType.DOUBLE)),
            HoodieSchemaField.of("boolean_col", HoodieSchema.create(HoodieSchemaType.BOOLEAN)),
            HoodieSchemaField.of("bytes_col", HoodieSchema.create(HoodieSchemaType.BYTES))
        )
    );

    RowType result = HoodieSchemaConverter.convertToRowType(hoodieSchema);

    assertEquals(7, result.getFieldCount());
    // Verify each field name
    assertEquals("string_col", result.getFieldNames().get(0));
    assertEquals("int_col", result.getFieldNames().get(1));
    assertEquals("long_col", result.getFieldNames().get(2));
    assertEquals("float_col", result.getFieldNames().get(3));
    assertEquals("double_col", result.getFieldNames().get(4));
    assertEquals("boolean_col", result.getFieldNames().get(5));
    assertEquals("bytes_col", result.getFieldNames().get(6));
  }

  @Test
  public void testConvertNullableTypesToDataType() {
    HoodieSchema hoodieSchema = HoodieSchema.createRecord(
        "test_record",
        null,
        null,
        Arrays.asList(
            HoodieSchemaField.of("nullable_string",
                HoodieSchema.createNullable(HoodieSchema.create(HoodieSchemaType.STRING))),
            HoodieSchemaField.of("nullable_int",
                HoodieSchema.createNullable(HoodieSchema.create(HoodieSchemaType.INT)))
        )
    );

    RowType result = HoodieSchemaConverter.convertToRowType(hoodieSchema);

    assertTrue(result.getTypeAt(0).isNullable());
    assertTrue(result.getTypeAt(1).isNullable());
    assertEquals("nullable_string", result.getFieldNames().get(0));
    assertEquals("nullable_int", result.getFieldNames().get(1));
  }

  @Test
  public void testConvertTemporalTypesToDataType() {
    HoodieSchema hoodieSchema = HoodieSchema.createRecord(
        "test_record",
        null,
        null,
        Arrays.asList(
            HoodieSchemaField.of("date_col", HoodieSchema.createDate()),
            HoodieSchemaField.of("time_col", HoodieSchema.createTimeMillis()),
            HoodieSchemaField.of("timestamp_millis", HoodieSchema.createTimestampMillis()),
            HoodieSchemaField.of("timestamp_micros", HoodieSchema.createTimestampMicros()),
            HoodieSchemaField.of("local_timestamp_millis", HoodieSchema.createLocalTimestampMillis()),
            HoodieSchemaField.of("local_timestamp_micros", HoodieSchema.createLocalTimestampMicros())
        )
    );

    RowType result = HoodieSchemaConverter.convertToRowType(hoodieSchema);

    assertEquals(6, result.getFieldCount());
    assertEquals(3, ((TimestampType) result.getTypeAt(2)).getPrecision());
    assertEquals(6, ((TimestampType) result.getTypeAt(3)).getPrecision());
  }

  @Test
  public void testConvertDecimalTypeToDataType() {
    // Create a FIXED-backed decimal HoodieSchema directly
    HoodieSchema decimalSchema = HoodieSchema.createDecimal(
        "decimal_col.fixed",  // name
        null,                 // namespace
        null,                 // doc
        10,                   // precision
        2,                    // scale
        5                     // fixedSize (5 bytes for precision 10)
    );

    // Wrap in a record structure
    HoodieSchema hoodieSchema = HoodieSchema.createRecord(
        "test_record",
        null,
        null,
        Arrays.asList(HoodieSchemaField.of("decimal_col", decimalSchema))
    );

    // Verify the decimal is FIXED-backed (backed by fixed-size byte array)
    HoodieSchema decimalField = hoodieSchema.getFields().get(0).schema();
    assertTrue(decimalField instanceof HoodieSchema.Decimal);
    HoodieSchema.Decimal decimalSchemaTyped = (HoodieSchema.Decimal) decimalField;
    assertTrue(decimalSchemaTyped.isFixed()); // Verify it's FIXED-backed
    assertEquals(5, decimalSchemaTyped.getFixedSize()); // For precision 10, fixed size is 5 bytes
    assertEquals(10, decimalSchemaTyped.getPrecision());
    assertEquals(2, decimalSchemaTyped.getScale());

    // Convert to Flink RowType
    RowType result = HoodieSchemaConverter.convertToRowType(hoodieSchema);

    // Verify conversion
    assertTrue(result.getTypeAt(0) instanceof DecimalType);
    DecimalType decimal = (DecimalType) result.getTypeAt(0);
    assertEquals(10, decimal.getPrecision());
    assertEquals(2, decimal.getScale());
  }

  @Test
  public void testConvertComplexTypesToDataType() {
    HoodieSchema nestedRecord = HoodieSchema.createRecord(
        "nested_record",
        null,
        null,
        Arrays.asList(
            HoodieSchemaField.of("nested_id", HoodieSchema.create(HoodieSchemaType.INT)),
            HoodieSchemaField.of("nested_name", HoodieSchema.create(HoodieSchemaType.STRING))
        )
    );

    HoodieSchema hoodieSchema = HoodieSchema.createRecord(
        "test_record",
        null,
        null,
        Arrays.asList(
            HoodieSchemaField.of("array_col",
                HoodieSchema.createArray(HoodieSchema.create(HoodieSchemaType.STRING))),
            HoodieSchemaField.of("map_col",
                HoodieSchema.createMap(HoodieSchema.create(HoodieSchemaType.INT))),
            HoodieSchemaField.of("nested_record", nestedRecord)
        )
    );

    RowType result = HoodieSchemaConverter.convertToRowType(hoodieSchema);

    assertEquals(3, result.getFieldCount());
    assertTrue(result.getTypeAt(0) instanceof ArrayType);
    assertTrue(result.getTypeAt(1) instanceof MapType);
    assertTrue(result.getTypeAt(2) instanceof RowType);
  }

  @Test
  public void testConvertNullSchemaThrowsException() {
    assertThrows(IllegalArgumentException.class, () -> {
      HoodieSchemaConverter.convertToRowType(null);
    });
  }

  @Test
  public void testConvertNonRecordSchemaThrowsException() {
    HoodieSchema stringSchema = HoodieSchema.create(HoodieSchemaType.STRING);
    assertThrows(IllegalArgumentException.class, () -> {
      HoodieSchemaConverter.convertToRowType(stringSchema);
    });
  }

  @Test
  public void testEnumToStringConversion() {
    HoodieSchema enumSchema = HoodieSchema.createEnum(
        "Color", null, null, Arrays.asList("RED", "GREEN", "BLUE"));

    DataType dataType = HoodieSchemaConverter.convertToDataType(enumSchema);
    assertTrue(dataType.getLogicalType() instanceof VarCharType);
  }

  @Test
  public void testFixedConversion() {
    HoodieSchema fixedSchema = HoodieSchema.createFixed("MD5", null, null, 16);
    DataType dataType = HoodieSchemaConverter.convertToDataType(fixedSchema);
    assertTrue(dataType.getLogicalType() instanceof VarBinaryType);
  }

  @Test
  public void testVectorConversion() {
    HoodieSchema floatVectorSchema = HoodieSchema.createVector(128);
    HoodieSchema doubleVectorSchema = HoodieSchema.createVector(128, HoodieSchema.Vector.VectorElementType.DOUBLE);
    HoodieSchema int8VectorSchema = HoodieSchema.createVector(128, HoodieSchema.Vector.VectorElementType.INT8);

    DataType floatDataType = HoodieSchemaConverter.convertToDataType(floatVectorSchema);
    DataType doubleDataType = HoodieSchemaConverter.convertToDataType(doubleVectorSchema);
    DataType int8DataType = HoodieSchemaConverter.convertToDataType(int8VectorSchema);

    assertVectorArray(floatDataType, LogicalTypeRoot.FLOAT, false);
    assertVectorArray(doubleDataType, LogicalTypeRoot.DOUBLE, false);
    assertVectorArray(int8DataType, LogicalTypeRoot.TINYINT, false);
  }

  @Test
  public void testNullableVectorConversion() {
    HoodieSchema vectorSchema = HoodieSchema.createNullable(HoodieSchema.createVector(128));

    DataType dataType = HoodieSchemaConverter.convertToDataType(vectorSchema);

    assertVectorArray(dataType, LogicalTypeRoot.FLOAT, true);
  }

  @Test
  public void testVectorInRecordConversion() {
    HoodieSchema schema = HoodieSchema.createRecord(
        "test_record",
        null,
        null,
        Arrays.asList(
            HoodieSchemaField.of("id", HoodieSchema.create(HoodieSchemaType.INT)),
            HoodieSchemaField.of("embedding", HoodieSchema.createVector(128))
        )
    );

    RowType rowType = HoodieSchemaConverter.convertToRowType(schema);

    assertEquals(2, rowType.getFieldCount());
    assertEquals("embedding", rowType.getFieldNames().get(1));
    ArrayType vectorArrayType = assertInstanceOf(ArrayType.class, rowType.getTypeAt(1));
    assertEquals(LogicalTypeRoot.FLOAT, vectorArrayType.getElementType().getTypeRoot());
    assertFalse(rowType.getTypeAt(1).isNullable());
  }

  private void assertVectorArray(DataType dataType, LogicalTypeRoot elementTypeRoot, boolean nullable) {
    ArrayType arrayType = assertInstanceOf(ArrayType.class, dataType.getLogicalType());
    assertEquals(elementTypeRoot, arrayType.getElementType().getTypeRoot());
    assertFalse(arrayType.getElementType().isNullable());
    assertEquals(nullable, dataType.getLogicalType().isNullable());
  }

  @Test
  void testUnionSchemaWithMultipleRecordTypes() {
    HoodieSchema schema = HoodieSchema.fromAvroSchema(HoodieMetadataRecord.SCHEMA$);
    DataType dataType = HoodieSchemaConverter.convertToDataType(schema);
    int pos = HoodieMetadataRecord.SCHEMA$.getField(HoodieMetadataPayload.SCHEMA_FIELD_ID_COLUMN_STATS).pos();
    final String expected = "ROW<"
        + "`fileName` STRING, "
        + "`columnName` STRING, "
        + "`minValue` ROW<`wrapper` RAW('java.lang.Object', ?) NOT NULL>, "
        + "`maxValue` ROW<`wrapper` RAW('java.lang.Object', ?) NOT NULL>, "
        + "`valueCount` BIGINT, "
        + "`nullCount` BIGINT, "
        + "`totalSize` BIGINT, "
        + "`totalUncompressedSize` BIGINT, "
        + "`isDeleted` BOOLEAN NOT NULL, "
        + "`isTightBound` BOOLEAN NOT NULL, "
        + "`valueType` ROW<`typeOrdinal` INT NOT NULL, `additionalInfo` STRING>>";
    assertEquals(expected, dataType.getChildren().get(pos).toString());
  }

  @Test
  void testLocalTimestampType() {
    DataType dataType = DataTypes.ROW(
        DataTypes.FIELD("f_localtimestamp_millis", DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3)),
        DataTypes.FIELD("f_localtimestamp_micros", DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6))
    );
    // convert to avro schema
    HoodieSchema schema = HoodieSchemaConverter.convertToSchema(dataType.getLogicalType());
    final String expectedSchema = ""
        + "[ \"null\", {\n"
        + "  \"type\" : \"record\",\n"
        + "  \"name\" : \"record\",\n"
        + "  \"fields\" : [ {\n"
        + "    \"name\" : \"f_localtimestamp_millis\",\n"
        + "    \"type\" : [ \"null\", {\n"
        + "      \"type\" : \"long\",\n"
        + "      \"logicalType\" : \"local-timestamp-millis\"\n"
        + "    } ],\n"
        + "    \"default\" : null\n"
        + "  }, {\n"
        + "    \"name\" : \"f_localtimestamp_micros\",\n"
        + "    \"type\" : [ \"null\", {\n"
        + "      \"type\" : \"long\",\n"
        + "      \"logicalType\" : \"local-timestamp-micros\"\n"
        + "    } ],\n"
        + "    \"default\" : null\n"
        + "  } ]\n"
        + "} ]";
    assertEquals(expectedSchema, schema.toString(true));
    // convert it back
    DataType convertedDataType = HoodieSchemaConverter.convertToDataType(schema);
    final String expectedDataType = "ROW<"
        + "`f_localtimestamp_millis` TIMESTAMP_LTZ(3), "
        + "`f_localtimestamp_micros` TIMESTAMP_LTZ(6)>";
    assertEquals(expectedDataType, convertedDataType.toString());
  }

  @Test
  public void testBlobTypeRoundTrip() {
    // Create a BLOB HoodieSchema
    HoodieSchema blobSchema = HoodieSchema.createBlob();
    assertEquals(HoodieSchemaType.BLOB, blobSchema.getType());

    // Convert to Flink DataType
    DataType dataType = HoodieSchemaConverter.convertToDataType(blobSchema);
    assertNotNull(dataType);

    // Verify it's a ROW structure with correct fields
    RowType rowType = (RowType) dataType.getLogicalType();
    assertEquals(3, rowType.getFieldCount());
    assertEquals(Arrays.asList(
        HoodieSchema.Blob.TYPE,
        HoodieSchema.Blob.INLINE_DATA_FIELD,
        HoodieSchema.Blob.EXTERNAL_REFERENCE),
        rowType.getFieldNames());

    // Convert back to HoodieSchema
    HoodieSchema convertedSchema = HoodieSchemaConverter.convertToSchema(rowType);
    assertEquals(HoodieSchemaType.BLOB, convertedSchema.getType());
    assertInstanceOf(HoodieSchema.Blob.class, convertedSchema);
  }

  @Test
  public void testNullableBlobRoundTrip() {
    // Create a nullable BLOB schema
    HoodieSchema nullableBlob = HoodieSchema.createNullable(HoodieSchema.createBlob());
    assertTrue(nullableBlob.isNullable());
    assertEquals(HoodieSchemaType.UNION, nullableBlob.getType());

    // Convert to Flink DataType
    DataType dataType = HoodieSchemaConverter.convertToDataType(nullableBlob);
    assertNotNull(dataType);
    assertTrue(dataType.getLogicalType().isNullable());

    // Verify underlying type is BLOB structure
    RowType rowType = (RowType) dataType.getLogicalType();
    assertEquals(3, rowType.getFieldCount());

    // Convert back to HoodieSchema
    HoodieSchema convertedSchema = HoodieSchemaConverter.convertToSchema(dataType.getLogicalType());
    assertTrue(convertedSchema.isNullable());

    // Verify underlying type is BLOB
    HoodieSchema nonNullSchema = convertedSchema.getNonNullType();
    assertEquals(HoodieSchemaType.BLOB, nonNullSchema.getType());
  }

  @Test
  public void testBlobInNestedStructures() {
    // Test 1: BLOB field within RECORD structure
    HoodieSchema recordWithBlob = HoodieSchema.createRecord(
        "test_record",
        null,
        null,
        Arrays.asList(
            HoodieSchemaField.of("id", HoodieSchema.create(HoodieSchemaType.INT)),
            HoodieSchemaField.of("blob_data", HoodieSchema.createBlob())
        )
    );

    DataType recordDataType = HoodieSchemaConverter.convertToDataType(recordWithBlob);
    RowType recordRowType = (RowType) recordDataType.getLogicalType();
    assertEquals(2, recordRowType.getFieldCount());

    // Verify blob field structure
    RowType blobFieldType = (RowType) recordRowType.getTypeAt(1);
    assertEquals(3, blobFieldType.getFieldCount());

    // Round-trip conversion
    HoodieSchema convertedRecord = HoodieSchemaConverter.convertToSchema(recordRowType, "test_record");
    assertEquals(HoodieSchemaType.RECORD, convertedRecord.getType());
    assertEquals(HoodieSchemaType.BLOB, convertedRecord.getFields().get(1).schema().getType());

    // Test 2: ARRAY of BLOBs
    HoodieSchema arrayOfBlobs = HoodieSchema.createArray(HoodieSchema.createBlob());
    DataType arrayDataType = HoodieSchemaConverter.convertToDataType(arrayOfBlobs);
    ArrayType arrayType = (ArrayType) arrayDataType.getLogicalType();

    // Verify element is BLOB structure
    RowType elementType = (RowType) arrayType.getElementType();
    assertEquals(3, elementType.getFieldCount());

    // Round-trip
    HoodieSchema convertedArray = HoodieSchemaConverter.convertToSchema(arrayType);
    assertEquals(HoodieSchemaType.ARRAY, convertedArray.getType());
    assertEquals(HoodieSchemaType.BLOB, convertedArray.getElementType().getType());

    // Test 3: MAP with BLOB values
    HoodieSchema mapWithBlobValues = HoodieSchema.createMap(HoodieSchema.createBlob());
    DataType mapDataType = HoodieSchemaConverter.convertToDataType(mapWithBlobValues);
    MapType mapType = (MapType) mapDataType.getLogicalType();

    // Verify value is BLOB structure
    RowType valueType = (RowType) mapType.getValueType();
    assertEquals(3, valueType.getFieldCount());

    // Round-trip
    HoodieSchema convertedMap = HoodieSchemaConverter.convertToSchema(mapType);
    assertEquals(HoodieSchemaType.MAP, convertedMap.getType());
    assertEquals(HoodieSchemaType.BLOB, convertedMap.getValueType().getType());
  }

  @Test
  public void testVariantTypeConversion() {
    // Test direct Variant conversion
    HoodieSchema variantSchema = HoodieSchema.createVariant();
    DataType dataType = HoodieSchemaConverter.convertToDataType(variantSchema);
    assertNotNull(dataType);

    // Verify it's a ROW with metadata and value binary fields
    RowType rowType = (RowType) dataType.getLogicalType();
    assertEquals(2, rowType.getFieldCount());
    assertEquals("metadata", rowType.getFieldNames().get(0));
    assertEquals("value", rowType.getFieldNames().get(1));
    assertInstanceOf(VarBinaryType.class, rowType.getTypeAt(0));
    assertInstanceOf(VarBinaryType.class, rowType.getTypeAt(1));
  }

  @Test
  public void testVariantInRecordConversion() {
    // Test Variant field within a record
    HoodieSchema recordWithVariant = HoodieSchema.createRecord(
        "test_record",
        null,
        null,
        Arrays.asList(
            HoodieSchemaField.of("id", HoodieSchema.create(HoodieSchemaType.INT)),
            HoodieSchemaField.of("data", HoodieSchema.createVariant())
        )
    );

    RowType result = HoodieSchemaConverter.convertToRowType(recordWithVariant);
    assertEquals(2, result.getFieldCount());
    assertEquals("data", result.getFieldNames().get(1));

    // Verify variant field is a ROW<metadata BYTES, value BYTES>
    RowType variantRowType = (RowType) result.getTypeAt(1);
    assertEquals(2, variantRowType.getFieldCount());
    assertEquals("metadata", variantRowType.getFieldNames().get(0));
    assertEquals("value", variantRowType.getFieldNames().get(1));
  }

  @Test
  public void testBlobStructureValidation() {
    // Positive case: Create ROW matching BLOB structure
    DataType blobLikeRow = DataTypes.ROW(
        DataTypes.FIELD(HoodieSchema.Blob.TYPE, DataTypes.STRING().notNull()),
        DataTypes.FIELD(HoodieSchema.Blob.INLINE_DATA_FIELD, DataTypes.BYTES().nullable()),
        DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE, DataTypes.ROW(
            DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH, DataTypes.STRING().notNull()),
            DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_OFFSET, DataTypes.BIGINT().nullable()),
            DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_LENGTH, DataTypes.BIGINT().nullable()),
            DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_IS_MANAGED, DataTypes.BOOLEAN().notNull())
        ).nullable())
    ).notNull();

    RowType blobLikeRowType = (RowType) blobLikeRow.getLogicalType();
    HoodieSchema convertedSchema = HoodieSchemaConverter.convertToSchema(blobLikeRowType);
    assertEquals(HoodieSchemaType.BLOB, convertedSchema.getType());

    // Negative case 1: Different field names
    DataType differentNames = DataTypes.ROW(
        DataTypes.FIELD("wrong_name", DataTypes.STRING().notNull()),
        DataTypes.FIELD("data", DataTypes.BYTES().nullable()),
        DataTypes.FIELD("reference", DataTypes.ROW(
            DataTypes.FIELD("external_path", DataTypes.STRING().notNull()),
            DataTypes.FIELD("offset", DataTypes.BIGINT().nullable()),
            DataTypes.FIELD("length", DataTypes.BIGINT().nullable()),
            DataTypes.FIELD("managed", DataTypes.BOOLEAN().notNull())
        ).nullable())
    ).notNull();

    RowType differentNamesType = (RowType) differentNames.getLogicalType();
    HoodieSchema notBlob1 = HoodieSchemaConverter.convertToSchema(differentNamesType);
    assertEquals(HoodieSchemaType.RECORD, notBlob1.getType()); // Should be RECORD, not BLOB

    // Negative case 2: Wrong field types
    DataType wrongTypes = DataTypes.ROW(
        DataTypes.FIELD(HoodieSchema.Blob.TYPE, DataTypes.INT().notNull()), // Wrong type
        DataTypes.FIELD(HoodieSchema.Blob.INLINE_DATA_FIELD, DataTypes.BYTES().nullable()),
        DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE, DataTypes.ROW(
            DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH, DataTypes.STRING().notNull()),
            DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_OFFSET, DataTypes.BIGINT().nullable()),
            DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_LENGTH, DataTypes.BIGINT().nullable()),
            DataTypes.FIELD(HoodieSchema.Blob.EXTERNAL_REFERENCE_IS_MANAGED, DataTypes.BOOLEAN().notNull())
        ).nullable())
    ).notNull();

    RowType wrongTypesType = (RowType) wrongTypes.getLogicalType();
    HoodieSchema notBlob2 = HoodieSchemaConverter.convertToSchema(wrongTypesType);
    assertEquals(HoodieSchemaType.RECORD, notBlob2.getType()); // Should be RECORD, not BLOB
  }
}
