/*
 * 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.gravitino.lance.integration.test;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;
import org.apache.arrow.vector.types.pojo.ArrowType;
import org.apache.arrow.vector.types.pojo.Field;
import org.apache.commons.io.FileUtils;
import org.apache.gravitino.Catalog;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Schema;
import org.apache.gravitino.client.GravitinoMetalake;
import org.apache.gravitino.integration.test.util.BaseIT;
import org.apache.gravitino.integration.test.util.GravitinoITUtils;
import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.lance.common.utils.ArrowUtils;
import org.apache.gravitino.lance.common.utils.LanceConstants;
import org.apache.gravitino.rel.Table;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.lance.namespace.LanceNamespace;
import org.lance.namespace.client.apache.ApiClient;
import org.lance.namespace.client.apache.ApiException;
import org.lance.namespace.client.apache.api.TableApi;
import org.lance.namespace.errors.ErrorCode;
import org.lance.namespace.errors.LanceNamespaceException;
import org.lance.namespace.model.AlterColumnsEntry;
import org.lance.namespace.model.AlterTableAlterColumnsRequest;
import org.lance.namespace.model.AlterTableAlterColumnsResponse;
import org.lance.namespace.model.AlterTableDropColumnsRequest;
import org.lance.namespace.model.AlterTableDropColumnsResponse;
import org.lance.namespace.model.CreateNamespaceRequest;
import org.lance.namespace.model.CreateNamespaceResponse;
import org.lance.namespace.model.CreateTableResponse;
import org.lance.namespace.model.DeclareTableRequest;
import org.lance.namespace.model.DeclareTableResponse;
import org.lance.namespace.model.DeregisterTableRequest;
import org.lance.namespace.model.DeregisterTableResponse;
import org.lance.namespace.model.DescribeNamespaceRequest;
import org.lance.namespace.model.DescribeNamespaceResponse;
import org.lance.namespace.model.DescribeTableRequest;
import org.lance.namespace.model.DescribeTableResponse;
import org.lance.namespace.model.DropNamespaceRequest;
import org.lance.namespace.model.DropNamespaceResponse;
import org.lance.namespace.model.DropTableRequest;
import org.lance.namespace.model.ErrorResponse;
import org.lance.namespace.model.JsonArrowField;
import org.lance.namespace.model.ListNamespacesRequest;
import org.lance.namespace.model.ListNamespacesResponse;
import org.lance.namespace.model.ListTablesRequest;
import org.lance.namespace.model.NamespaceExistsRequest;
import org.lance.namespace.model.RegisterTableRequest;
import org.lance.namespace.model.RegisterTableResponse;
import org.lance.namespace.model.TableExistsRequest;

@SuppressWarnings("deprecation")
public class LanceRESTServiceIT extends BaseIT {
  private static final String CATALOG_NAME = GravitinoITUtils.genRandomName("lance_rest_catalog");
  private static final String SCHEMA_NAME = GravitinoITUtils.genRandomName("lance_rest_schema");
  private static final String DELIMITER = ".";
  private static final String MINIO_ENDPOINT = "http://127.0.0.1:9000";
  private static final String MINIO_REGION = "us-east-1";
  private static final String MINIO_ACCESS_KEY = "minioadmin";
  private static final String MINIO_SECRET_KEY = "minioadmin";

  private GravitinoMetalake metalake;
  private Catalog catalog;
  private Map<String, String> properties =
      new HashMap<>() {
        {
          put("key1", "value1");
        }
      };
  private final BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE);
  private LanceNamespace ns;
  private Path tempDir;

  @BeforeAll
  public void startIntegrationTest() throws Exception {
    super.ignoreLanceAuxRestService = false;
    super.startIntegrationTest();
    this.metalake = createMetalake(getLanceRESTServerMetalakeName());

    HashMap<String, String> props = Maps.newHashMap();
    props.put("uri", getLanceRestServiceUrl());
    props.put("delimiter", DELIMITER);
    this.ns = LanceNamespace.connect("rest", props, allocator);

    this.tempDir = Files.createTempDirectory("test_lance_rest_service_it_");
  }

  @AfterAll
  public void clean() throws Exception {
    Exception failure = null;

    try {
      if (client != null) {
        client.dropMetalake(getLanceRESTServerMetalakeName(), true);
      }
    } catch (Exception e) {
      failure = appendFailure(failure, e);
    }

    try {
      if (tempDir != null) {
        FileUtils.deleteDirectory(tempDir.toFile());
      }
    } catch (Exception e) {
      failure = appendFailure(failure, e);
    }

    try {
      allocator.close();
    } catch (Exception e) {
      failure = appendFailure(failure, e);
    }

    try {
      super.stopIntegrationTest();
    } catch (Exception e) {
      failure = appendFailure(failure, e);
    }

    if (failure != null) {
      throw failure;
    }
  }

  @AfterEach
  public void clearMetalake() {
    Arrays.stream(metalake.listCatalogs()).forEach(c -> metalake.dropCatalog(c, true));
  }

  @Test
  public void testListNamespaces() {
    Catalog catalog1 = createCatalog(GravitinoITUtils.genRandomName("lance_catalog_1"));
    Catalog catalog2 = createCatalog(GravitinoITUtils.genRandomName("lance_catalog_2"));
    Schema schema1 =
        catalog1
            .asSchemas()
            .createSchema("lance_schema_1", "schema for lance rest service tests", null);

    // test list catalogs via lance rest namespace client
    ListNamespacesRequest listNamespacesReq = new ListNamespacesRequest();
    ListNamespacesResponse listNamespacesResp = ns.listNamespaces(listNamespacesReq);

    Assertions.assertEquals(
        Sets.newHashSet(catalog1.name(), catalog2.name()), listNamespacesResp.getNamespaces());

    // test list schemas via lance rest namespace client
    listNamespacesReq.addIdItem(catalog1.name());
    listNamespacesResp = ns.listNamespaces(listNamespacesReq);

    Assertions.assertEquals(Sets.newHashSet(schema1.name()), listNamespacesResp.getNamespaces());
  }

  @Test
  public void testDescribeNamespace() {
    Catalog catalog = createCatalog(GravitinoITUtils.genRandomName("lance_catalog"));
    Map<String, String> schemaProps =
        new HashMap<>() {
          {
            put("schema_key1", "schema_value1");
          }
        };
    Schema schema = catalog.asSchemas().createSchema("lance_schema", null, schemaProps);

    // test describe catalog via lance rest namespace client
    DescribeNamespaceRequest describeNamespaceReq = new DescribeNamespaceRequest();
    describeNamespaceReq.addIdItem(catalog.name());
    DescribeNamespaceResponse describeNamespaceResp = ns.describeNamespace(describeNamespaceReq);

    Assertions.assertEquals(catalog.properties(), describeNamespaceResp.getProperties());

    // test describe schema via lance rest namespace client
    describeNamespaceReq.addIdItem(schema.name());
    describeNamespaceResp = ns.describeNamespace(describeNamespaceReq);

    Assertions.assertEquals(schema.properties(), describeNamespaceResp.getProperties());

    // test describe a non-existent catalog namespace
    DescribeNamespaceRequest nonExistentCatalogReq = new DescribeNamespaceRequest();
    nonExistentCatalogReq.addIdItem("non_existent_catalog");
    RuntimeException exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.describeNamespace(nonExistentCatalogReq));
    assertLanceErrorCode(exception, ErrorCode.NAMESPACE_NOT_FOUND);

    // test describe a non-existent schema namespace
    DescribeNamespaceRequest nonExistentSchemaReq = new DescribeNamespaceRequest();
    nonExistentSchemaReq.addIdItem(catalog.name());
    nonExistentSchemaReq.addIdItem("non_existent_schema");
    exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.describeNamespace(nonExistentSchemaReq));
    assertLanceErrorCode(exception, ErrorCode.NAMESPACE_NOT_FOUND);
  }

  @Test
  public void testCreateNamespace() {
    String catalogName = GravitinoITUtils.genRandomName("lance_catalog");
    Map<String, String> catalogProps =
        new HashMap<>() {
          {
            put("catalog_key1", "catalog_value1");
          }
        };

    // test create catalog via lance rest namespace client
    CreateNamespaceRequest createNamespaceReq = new CreateNamespaceRequest();
    createNamespaceReq.addIdItem(catalogName);
    createNamespaceReq.setProperties(catalogProps);
    CreateNamespaceResponse createNamespaceResp = ns.createNamespace(createNamespaceReq);

    Catalog catalog = metalake.loadCatalog(catalogName);
    Assertions.assertEquals(catalog.properties(), createNamespaceResp.getProperties());

    // create catalog again with default mode (create) should fail
    RuntimeException exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.createNamespace(createNamespaceReq));
    assertLanceErrorCode(exception, ErrorCode.NAMESPACE_ALREADY_EXISTS);

    // create catalog again with exist_ok mode should succeed
    createNamespaceReq.setMode("exist_ok");
    createNamespaceResp = ns.createNamespace(createNamespaceReq);
    Assertions.assertEquals(catalog.properties(), createNamespaceResp.getProperties());

    // create catalog again with overwrite mode should succeed and update properties
    Map<String, String> newProps =
        new HashMap<>(catalogProps) {
          {
            put("catalog_key2", "catalog_value2");
          }
        };
    createNamespaceReq.setMode("overwrite");
    createNamespaceReq.setProperties(newProps);
    createNamespaceResp = ns.createNamespace(createNamespaceReq);

    catalog = metalake.loadCatalog(catalogName);
    Assertions.assertEquals(catalog.properties(), createNamespaceResp.getProperties());
    Assertions.assertEquals(catalog.properties(), createNamespaceResp.getProperties());

    // test create schema via lance rest namespace client
    CreateNamespaceRequest createSchemaReq = new CreateNamespaceRequest();
    String schemaName = "lance_schema";
    Map<String, String> schemaProps =
        new HashMap<>() {
          {
            put("schema_key1", "schema_value1");
          }
        };
    createSchemaReq.addIdItem(catalogName);
    createSchemaReq.addIdItem(schemaName);
    createSchemaReq.setProperties(schemaProps);
    createNamespaceResp = ns.createNamespace(createSchemaReq);

    Schema schema = catalog.asSchemas().loadSchema(schemaName);
    Assertions.assertEquals(schema.properties(), createNamespaceResp.getProperties());

    // create schema again with default mode (create) should fail
    exception =
        Assertions.assertThrows(RuntimeException.class, () -> ns.createNamespace(createSchemaReq));
    assertLanceErrorCode(exception, ErrorCode.NAMESPACE_ALREADY_EXISTS);

    // create schema again with exist_ok mode should succeed
    createSchemaReq.setMode("exist_ok");
    createNamespaceResp = ns.createNamespace(createSchemaReq);
    Assertions.assertEquals(schema.properties(), createNamespaceResp.getProperties());

    // create schema again with overwrite mode should succeed and update properties
    Map<String, String> newSchemaProps =
        new HashMap<>(schemaProps) {
          {
            put("schema_key2", "schema_value2");
          }
        };
    createSchemaReq.setMode("overwrite");
    createSchemaReq.setProperties(newSchemaProps);
    createNamespaceResp = ns.createNamespace(createSchemaReq);

    schema = catalog.asSchemas().loadSchema(schemaName);
    Assertions.assertEquals(schema.properties(), createNamespaceResp.getProperties());
  }

  @Test
  public void testDropNamespace() {
    Catalog catalog = createCatalog(GravitinoITUtils.genRandomName("lance_catalog"));
    Schema schema = catalog.asSchemas().createSchema("lance_schema", null, null);

    // test drop a non-existent namespace (catalog) with default mode (FAIL) should fail
    DropNamespaceRequest dropNamespaceReq = new DropNamespaceRequest();
    dropNamespaceReq.addIdItem("non_existent_catalog");
    RuntimeException exception =
        Assertions.assertThrows(RuntimeException.class, () -> ns.dropNamespace(dropNamespaceReq));
    assertLanceErrorCode(exception, ErrorCode.NAMESPACE_NOT_FOUND);

    // test drop a non-existent namespace (catalog) with SKIP mode should succeed
    dropNamespaceReq.setMode("skip");
    DropNamespaceResponse dropNamespaceResp = ns.dropNamespace(dropNamespaceReq);
    Assertions.assertTrue(dropNamespaceResp.getTransactionId().isEmpty());

    // test drop a non-existent namespace (schema) with default mode (FAIL) should fail
    DropNamespaceRequest dropSchemaReq = new DropNamespaceRequest();
    dropSchemaReq.addIdItem(catalog.name());
    dropSchemaReq.addIdItem("non_existent_schema");
    exception =
        Assertions.assertThrows(RuntimeException.class, () -> ns.dropNamespace(dropSchemaReq));
    assertLanceErrorCode(exception, ErrorCode.NAMESPACE_NOT_FOUND);

    // test drop a non-existent namespace (schema) with SKIP mode should succeed
    dropSchemaReq.setMode("skip");
    dropNamespaceResp = ns.dropNamespace(dropSchemaReq);
    Assertions.assertTrue(dropNamespaceResp.getTransactionId().isEmpty());

    // test drop a non-empty namespace (catalog) with default behavior (RESTRICT) should fail
    DropNamespaceRequest dropNonEmptyCatalogReq = new DropNamespaceRequest();
    dropNonEmptyCatalogReq.addIdItem(catalog.name());
    exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.dropNamespace(dropNonEmptyCatalogReq));
    assertLanceErrorCode(exception, ErrorCode.INVALID_INPUT);

    // test drop a non-empty namespace (catalog) with CASCADE behavior should succeed
    dropNonEmptyCatalogReq.setBehavior("cascade");
    dropNamespaceResp = ns.dropNamespace(dropNonEmptyCatalogReq);
    Assertions.assertTrue(dropNamespaceResp.getTransactionId().isEmpty());
    Assertions.assertFalse(metalake.catalogExists(catalog.name()));

    // recreate catalog, schema, and table for next test
    catalog = createCatalog(catalog.name());
    schema = catalog.asSchemas().createSchema(schema.name(), null, null);
    String tableName = GravitinoITUtils.genRandomName("test_lance_table");
    String tableLocation =
        Path.of(tempDir.toString(), catalog.name(), schema.name(), tableName).toString();
    catalog
        .asTableCatalog()
        .createTable(
            NameIdentifier.of(schema.name(), tableName),
            null,
            null,
            ImmutableMap.of("location", tableLocation, "format", "lance"));
    // test drop a non-empty namespace (schema) with default behavior (RESTRICT) should fail
    DropNamespaceRequest dropNonEmptySchemaReq = new DropNamespaceRequest();
    dropNonEmptySchemaReq.addIdItem(catalog.name());
    dropNonEmptySchemaReq.addIdItem(schema.name());
    exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.dropNamespace(dropNonEmptySchemaReq));
    assertLanceErrorCode(exception, ErrorCode.INVALID_INPUT);
    Assertions.assertTrue(catalog.asSchemas().schemaExists(schema.name()));

    // test drop a non-empty namespace (schema) with CASCADE behavior should succeed
    dropNonEmptySchemaReq.setBehavior("cascade");
    dropNamespaceResp = ns.dropNamespace(dropNonEmptySchemaReq);
    Assertions.assertTrue(dropNamespaceResp.getTransactionId().isEmpty());
    Assertions.assertFalse(catalog.asSchemas().schemaExists(schema.name()));
  }

  @Test
  public void testNamespaceExists() {
    Catalog catalog = createCatalog(GravitinoITUtils.genRandomName("lance_catalog"));
    Schema schema = catalog.asSchemas().createSchema("lance_schema", null, null);

    // test existing catalog
    NamespaceExistsRequest catalogExistsReq = new NamespaceExistsRequest();
    catalogExistsReq.addIdItem(catalog.name());
    Assertions.assertDoesNotThrow(() -> ns.namespaceExists(catalogExistsReq));

    // test non-existing catalog
    NamespaceExistsRequest nonExistentCatalogReq = new NamespaceExistsRequest();
    nonExistentCatalogReq.addIdItem("non_existent_catalog");
    RuntimeException exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.namespaceExists(nonExistentCatalogReq));
    assertLanceErrorCode(exception, ErrorCode.NAMESPACE_NOT_FOUND);

    // test existing schema
    NamespaceExistsRequest schemaExistsReq = new NamespaceExistsRequest();
    schemaExistsReq.addIdItem(catalog.name());
    schemaExistsReq.addIdItem(schema.name());
    Assertions.assertDoesNotThrow(() -> ns.namespaceExists(schemaExistsReq));

    // test non-existing schema
    NamespaceExistsRequest nonExistentSchemaReq = new NamespaceExistsRequest();
    nonExistentSchemaReq.addIdItem(catalog.name());
    nonExistentSchemaReq.addIdItem("non_existent_schema");
    exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.namespaceExists(nonExistentSchemaReq));
    assertLanceErrorCode(exception, ErrorCode.NAMESPACE_NOT_FOUND);
  }

  @Test
  void testCreateTable() throws IOException {
    catalog = createCatalog(CATALOG_NAME);
    createSchema();

    String location = tempDir + "/" + "table/";
    List<String> ids = List.of(CATALOG_NAME, SCHEMA_NAME, "table");
    org.apache.arrow.vector.types.pojo.Schema schema =
        new org.apache.arrow.vector.types.pojo.Schema(
            Arrays.asList(
                Field.nullable("id", new ArrowType.Int(32, true)),
                Field.nullable("value", new ArrowType.Utf8())));
    byte[] body = ArrowUtils.generateIpcStream(schema);

    Map<String, String> createTableProperties =
        ImmutableMap.of(
            "key1", "v1",
            "lance.storage.a", "value_a",
            "lance.storage.b", "value_b");
    CreateTableResponse response =
        createTable(ids, location, createTableProperties, body, /* mode= */ null);
    Assertions.assertNotNull(response);
    Assertions.assertEquals(location, response.getLocation());
    Assertions.assertEquals("value_a", response.getStorageOptions().get("a"));
    Assertions.assertEquals("value_b", response.getStorageOptions().get("b"));
    Assertions.assertNotNull(response.getVersion());

    DescribeTableRequest describeTableRequest = new DescribeTableRequest();
    describeTableRequest.setId(ids);
    DescribeTableResponse loadTable = ns.describeTable(describeTableRequest);
    Assertions.assertNotNull(loadTable);
    Assertions.assertEquals(location, loadTable.getLocation());
    Assertions.assertNotNull(loadTable.getVersion());

    describeTableRequest.setVersion(loadTable.getVersion());
    DescribeTableResponse versionedLoadTable = ns.describeTable(describeTableRequest);
    Assertions.assertNotNull(versionedLoadTable);
    Assertions.assertEquals(location, versionedLoadTable.getLocation());
    Assertions.assertEquals(loadTable.getVersion(), versionedLoadTable.getVersion());
    Assertions.assertEquals(loadTable.getStorageOptions(), versionedLoadTable.getStorageOptions());

    List<JsonArrowField> jsonArrowFields = loadTable.getSchema().getFields();
    for (int i = 0; i < jsonArrowFields.size(); i++) {
      JsonArrowField jsonArrowField = jsonArrowFields.get(i);
      Field originalField = schema.getFields().get(i);
      Assertions.assertEquals(originalField.getName(), jsonArrowField.getName());

      if (i == 0) {
        Assertions.assertEquals("int32", jsonArrowField.getType().getType());
      } else if (i == 1) {
        Assertions.assertEquals("utf8", jsonArrowField.getType().getType());
      }
    }
    // Check the location exists
    Assertions.assertTrue(new File(location).exists());
    Assertions.assertEquals("v1", loadTable.getMetadata().get("key1"));
    Assertions.assertEquals("value_a", loadTable.getStorageOptions().get("a"));
    Assertions.assertEquals("value_b", loadTable.getStorageOptions().get("b"));

    // Check overwrite mode
    String newLocation = tempDir + "/" + "table_new/";
    Map<String, String> overwriteTableProperties =
        ImmutableMap.of(
            "key1", "v2",
            "lance.storage.a", "value_va",
            "lance.storage.b", "value_vb");

    response =
        Assertions.assertDoesNotThrow(
            () -> createTable(ids, newLocation, overwriteTableProperties, body, "overwrite"));

    Assertions.assertNotNull(response);
    Assertions.assertEquals(newLocation, response.getLocation());
    Assertions.assertEquals("value_va", response.getStorageOptions().get("a"));
    Assertions.assertEquals("value_vb", response.getStorageOptions().get("b"));
    Assertions.assertTrue(new File(newLocation).exists());
    Assertions.assertFalse(new File(location).exists());

    // Check exist_ok mode
    response =
        Assertions.assertDoesNotThrow(
            () -> createTable(ids, newLocation, overwriteTableProperties, body, "exist_ok"));

    Assertions.assertNotNull(response);
    Assertions.assertEquals("value_va", response.getStorageOptions().get("a"));
    Assertions.assertEquals("value_vb", response.getStorageOptions().get("b"));
    Assertions.assertEquals(newLocation, response.getLocation());
    Assertions.assertTrue(new File(newLocation).exists());

    // Create table again without overwrite or exist_ok should fail
    LanceNamespaceException exception =
        Assertions.assertThrows(
            LanceNamespaceException.class,
            () -> createTable(ids, newLocation, overwriteTableProperties, body, "create"));
    Assertions.assertTrue(exception.getMessage().contains("already exists"));
    Assertions.assertEquals(5, exception.getCode());

    // Create a table without location should fail
    Assertions.assertThrows(
        LanceNamespaceException.class,
        () ->
            createTable(
                List.of(CATALOG_NAME, SCHEMA_NAME, "no_location_table"),
                /* location= */ null,
                ImmutableMap.of(),
                body,
                /* mode= */ null));

    // Create table with invalid schema should fail
    byte[] invalidBody = "".getBytes(Charset.defaultCharset());
    LanceNamespaceException apiException =
        Assertions.assertThrows(
            LanceNamespaceException.class,
            () ->
                createTable(
                    List.of(CATALOG_NAME, SCHEMA_NAME, "invalid_table"),
                    tempDir + "/" + "invalid_table/",
                    ImmutableMap.of(),
                    invalidBody,
                    /* mode= */ null));
    Assertions.assertTrue(apiException.getMessage().contains("Failed to parse Arrow IPC stream"));
    Assertions.assertEquals(13, apiException.getCode());

    // Create table with wrong ids should fail
    LanceNamespaceException wrongIdException =
        Assertions.assertThrows(
            LanceNamespaceException.class,
            () ->
                createTable(
                    List.of(CATALOG_NAME, "wrong_schema"),
                    tempDir + "/" + "wrong_id_table/",
                    ImmutableMap.of(),
                    body,
                    /* mode= */ null));
    Assertions.assertTrue(wrongIdException.getMessage().contains("Expected at 3-level namespace"));
    Assertions.assertEquals(13, wrongIdException.getCode());

    // Now test list tables
    ListTablesRequest listRequest = new ListTablesRequest();
    listRequest.setId(List.of(CATALOG_NAME, SCHEMA_NAME));
    var listResponse = ns.listTables(listRequest);
    Set<String> stringSet = listResponse.getTables();
    Assertions.assertEquals(1, stringSet.size());
    Assertions.assertTrue(stringSet.contains("table"));
    Assertions.assertFalse(stringSet.contains(String.join(DELIMITER, ids)));

    // Now try to drop columns in the table
    AlterTableDropColumnsRequest dropColumnsRequest = new AlterTableDropColumnsRequest();
    dropColumnsRequest.setId(List.of(CATALOG_NAME, SCHEMA_NAME, "table"));
    dropColumnsRequest.setColumns(List.of("value"));

    TableApi tableApi = createTableApi();
    String delimiter = DELIMITER;

    AlterTableDropColumnsResponse alterTableDropColumnsResponse =
        Assertions.assertDoesNotThrow(
            () ->
                tableApi.alterTableDropColumns(
                    String.join(delimiter, ids), dropColumnsRequest, delimiter));
    Assertions.assertNotNull(alterTableDropColumnsResponse);
    Assertions.assertNotNull(alterTableDropColumnsResponse.getVersion());

    describeTableRequest.setId(ids);
    loadTable = ns.describeTable(describeTableRequest);
    Assertions.assertNotNull(loadTable);
    Assertions.assertEquals(newLocation, loadTable.getLocation());

    jsonArrowFields = loadTable.getSchema().getFields();
    Assertions.assertEquals(1, jsonArrowFields.size());
    JsonArrowField jsonArrowField = jsonArrowFields.get(0);
    Assertions.assertEquals("id", jsonArrowField.getName());
    Assertions.assertEquals("int32", jsonArrowField.getType().getType());

    // Drop a non-existing column should fail
    AlterTableDropColumnsRequest dropNonExistingColumnsRequest = new AlterTableDropColumnsRequest();
    dropNonExistingColumnsRequest.setId(ids);
    dropNonExistingColumnsRequest.setColumns(List.of("non_existing_column"));
    Exception dropColumnException =
        Assertions.assertThrows(
            Exception.class,
            () ->
                tableApi.alterTableDropColumns(
                    String.join(delimiter, ids), dropNonExistingColumnsRequest, delimiter));
    Assertions.assertTrue(
        dropColumnException
            .getMessage()
            .contains("Column non_existing_column does not exist in the dataset"));
  }

  @Test
  void testCreateTableUsesCatalogStorageOptions() throws IOException {
    catalog = createCatalog(GravitinoITUtils.genRandomName("lance_rest_catalog"));
    createSchema();

    String location = tempDir.resolve("catalog_storage_table").toString() + "/";
    List<String> ids = List.of(catalog.name(), SCHEMA_NAME, "catalog_storage_table");
    org.apache.arrow.vector.types.pojo.Schema schema =
        new org.apache.arrow.vector.types.pojo.Schema(
            Arrays.asList(
                Field.nullable("id", new ArrowType.Int(32, true)),
                Field.nullable("value", new ArrowType.Utf8())));
    byte[] body = ArrowUtils.generateIpcStream(schema);

    CreateTableResponse response =
        createTable(ids, location, ImmutableMap.of("key1", "v1"), body, /* mode= */ null);
    Assertions.assertNotNull(response);
    Assertions.assertEquals(location, response.getLocation());
    Assertions.assertEquals(MINIO_ENDPOINT, response.getStorageOptions().get("endpoint"));
    Assertions.assertEquals("true", response.getStorageOptions().get("allow_http"));
    Assertions.assertEquals(MINIO_ACCESS_KEY, response.getStorageOptions().get("access_key_id"));
    Assertions.assertEquals(
        MINIO_SECRET_KEY, response.getStorageOptions().get("secret_access_key"));
    Assertions.assertEquals(MINIO_REGION, response.getStorageOptions().get("region"));

    DescribeTableRequest describeTableRequest = new DescribeTableRequest();
    describeTableRequest.setId(ids);
    DescribeTableResponse loadTable = ns.describeTable(describeTableRequest);
    Assertions.assertNotNull(loadTable);
    Assertions.assertEquals(MINIO_ENDPOINT, loadTable.getStorageOptions().get("endpoint"));
    Assertions.assertEquals("true", loadTable.getStorageOptions().get("allow_http"));
    Assertions.assertEquals(MINIO_ACCESS_KEY, loadTable.getStorageOptions().get("access_key_id"));
    Assertions.assertEquals(
        MINIO_SECRET_KEY, loadTable.getStorageOptions().get("secret_access_key"));
    Assertions.assertEquals(MINIO_REGION, loadTable.getStorageOptions().get("region"));
    Assertions.assertFalse(loadTable.getMetadata().containsKey("lance.storage.endpoint"));
    Assertions.assertFalse(loadTable.getMetadata().containsKey("lance.storage.access_key_id"));
    Assertions.assertFalse(loadTable.getMetadata().containsKey("lance.storage.secret_access_key"));
  }

  @Test
  void testAlterColumns() throws Exception {
    catalog = createCatalog(CATALOG_NAME);
    createSchema();

    String location = tempDir + "/" + "alter_columns/";
    List<String> ids = List.of(CATALOG_NAME, SCHEMA_NAME, "alter_columns_table");
    org.apache.arrow.vector.types.pojo.Schema schema =
        new org.apache.arrow.vector.types.pojo.Schema(
            Arrays.asList(
                Field.nullable("id", new ArrowType.Int(32, true)),
                Field.nullable("value", new ArrowType.Utf8())));
    byte[] body = ArrowUtils.generateIpcStream(schema);

    createTable(ids, location, ImmutableMap.of("key1", "v1"), body, /* mode= */ null);

    TableApi tableApi = createTableApi();
    String delimiter = DELIMITER;

    AlterTableAlterColumnsRequest alterRequest = new AlterTableAlterColumnsRequest();
    alterRequest.setId(ids);
    AlterColumnsEntry columnAlteration = new AlterColumnsEntry();
    columnAlteration.setPath("value");
    columnAlteration.setRename("value_new");
    alterRequest.setAlterations(List.of(columnAlteration));

    AlterTableAlterColumnsResponse response =
        Assertions.assertDoesNotThrow(
            () ->
                tableApi.alterTableAlterColumns(
                    String.join(delimiter, ids), alterRequest, delimiter));
    Assertions.assertNotNull(response);
    Assertions.assertNotNull(response.getVersion());

    DescribeTableRequest describeTableRequest = new DescribeTableRequest();
    describeTableRequest.setId(ids);
    DescribeTableResponse loadTable = ns.describeTable(describeTableRequest);
    Assertions.assertNotNull(loadTable);

    List<JsonArrowField> jsonArrowFields = loadTable.getSchema().getFields();
    Assertions.assertEquals(2, jsonArrowFields.size());
    Assertions.assertEquals("id", jsonArrowFields.get(0).getName());
    Assertions.assertEquals("value_new", jsonArrowFields.get(1).getName());
  }

  @Test
  void testRegisterTable() {
    catalog = createCatalog(CATALOG_NAME);
    createSchema();

    String location = tempDir + "/" + "register/";
    List<String> ids = List.of(CATALOG_NAME, SCHEMA_NAME, "table_register");
    RegisterTableRequest registerTableRequest = new RegisterTableRequest();
    registerTableRequest.setLocation(location);
    registerTableRequest.setMode("create");
    registerTableRequest.setId(ids);
    registerTableRequest.setProperties(ImmutableMap.of("key1", "value1"));

    RegisterTableResponse response = ns.registerTable(registerTableRequest);
    Assertions.assertNotNull(response);
    // The location should not exist yet as we do not create it in advanced.
    Assertions.assertEquals(location, response.getLocation());
    Assertions.assertFalse(new File(location).exists());

    DescribeTableRequest describeTableRequest = new DescribeTableRequest();
    describeTableRequest.setId(ids);
    DescribeTableResponse loadTable = ns.describeTable(describeTableRequest);
    Assertions.assertNotNull(loadTable);
    Assertions.assertEquals(location, loadTable.getLocation());
    Assertions.assertTrue(loadTable.getMetadata().containsKey("key1"));

    // Test register again with OVERWRITE mode
    String newLocation = tempDir + "/" + "register_new/";
    registerTableRequest.setMode("overwrite");
    registerTableRequest.setLocation(newLocation);
    response = Assertions.assertDoesNotThrow(() -> ns.registerTable(registerTableRequest));
    Assertions.assertNotNull(response);
    Assertions.assertEquals(newLocation, response.getLocation());
    // The location should not exist yet as we do not create it in advanced.
    Assertions.assertFalse(new File(newLocation).exists());

    // Register a new table with location exists
    String existingLocation = tempDir + "/" + "existing_location/";
    new File(existingLocation).mkdirs();
    Assertions.assertTrue(new File(existingLocation).exists());
    registerTableRequest.setMode("create");
    registerTableRequest.setLocation(existingLocation);
    registerTableRequest.setId(List.of(CATALOG_NAME, SCHEMA_NAME, "table_with_existing_location"));
    RegisterTableResponse existingLocationResponse =
        Assertions.assertDoesNotThrow(() -> ns.registerTable(registerTableRequest));
    Assertions.assertNotNull(existingLocationResponse);
    Assertions.assertEquals(existingLocation, existingLocationResponse.getLocation());
    Assertions.assertTrue(new File(existingLocation).exists());

    // Deregister the table with existing location
    DeregisterTableRequest deregisterExistingLocationRequest = new DeregisterTableRequest();
    deregisterExistingLocationRequest.setId(
        List.of(CATALOG_NAME, SCHEMA_NAME, "table_with_existing_location"));
    DeregisterTableResponse deregisterExistingLocationResponse =
        ns.deregisterTable(deregisterExistingLocationRequest);
    Assertions.assertNotNull(deregisterExistingLocationResponse);
    Assertions.assertEquals(existingLocation, deregisterExistingLocationResponse.getLocation());
    // Assert the location has not been dropped.
    Assertions.assertTrue(
        new File(Objects.requireNonNull(deregisterExistingLocationResponse.getLocation()))
            .exists());
    new File(existingLocation).deleteOnExit();

    // Test deregister table
    DeregisterTableRequest deregisterTableRequest = new DeregisterTableRequest();
    deregisterTableRequest.setId(ids);
    DeregisterTableResponse deregisterTableResponse = ns.deregisterTable(deregisterTableRequest);
    Assertions.assertNotNull(deregisterTableResponse);
    Assertions.assertEquals(newLocation, deregisterTableResponse.getLocation());

    // Test Overwrite again after deregister
    String nonExistingLocation = tempDir + "/" + "non_existing_location/";
    registerTableRequest.setMode("overwrite");
    registerTableRequest.setId(ids);
    registerTableRequest.setLocation(nonExistingLocation);
    response = Assertions.assertDoesNotThrow(() -> ns.registerTable(registerTableRequest));
    Assertions.assertNotNull(response);
    Assertions.assertEquals(nonExistingLocation, response.getLocation());
    Assertions.assertFalse(new File(nonExistingLocation).exists());

    // Test registerTable with null mode after deregister should succeed
    registerTableRequest.setMode(null);
    String verifyNullModeLocation = tempDir + "/" + "verify_null_mode_location/";
    registerTableRequest.setLocation(verifyNullModeLocation);
    registerTableRequest.setId(List.of(CATALOG_NAME, SCHEMA_NAME, "verify_null_mode_location"));
    response = Assertions.assertDoesNotThrow(() -> ns.registerTable(registerTableRequest));
    Assertions.assertNotNull(response);
    Assertions.assertEquals(verifyNullModeLocation, response.getLocation());
  }

  @Test
  void testDeregisterNonExistingTable() {
    catalog = createCatalog(CATALOG_NAME);
    createSchema();

    List<String> ids = List.of(CATALOG_NAME, SCHEMA_NAME, "non_existing_table");
    DeregisterTableRequest deregisterTableRequest = new DeregisterTableRequest();
    deregisterTableRequest.setId(ids);

    RuntimeException exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.deregisterTable(deregisterTableRequest));
    assertLanceErrorCode(exception, ErrorCode.TABLE_NOT_FOUND);
    Assertions.assertTrue(exception.getMessage().contains("Table not found"));
    // Try to create a table and then deregister table
    DeclareTableRequest declareTableRequest = new DeclareTableRequest();
    String location = tempDir + "/" + "to_be_deregistered_table/";
    ids = List.of(CATALOG_NAME, SCHEMA_NAME, "to_be_deregistered_table");
    declareTableRequest.setLocation(location);
    declareTableRequest.setId(ids);
    DeclareTableResponse response =
        Assertions.assertDoesNotThrow(() -> ns.declareTable(declareTableRequest));
    Assertions.assertNotNull(response);
    Assertions.assertEquals(location, response.getLocation());

    // Now try to deregister
    deregisterTableRequest.setId(ids);
    DeregisterTableResponse deregisterTableResponse =
        Assertions.assertDoesNotThrow(() -> ns.deregisterTable(deregisterTableRequest));
    Assertions.assertNotNull(deregisterTableResponse);
    Assertions.assertEquals(location, deregisterTableResponse.getLocation());
    Assertions.assertTrue(Objects.equals(ids, deregisterTableResponse.getId()));
    Assertions.assertFalse(
        new File(location).exists(), "Data should still exist after deregistering the table.");

    // Now try to describe the table, should fail
    DescribeTableRequest describeTableRequest = new DescribeTableRequest();
    describeTableRequest.setId(ids);
    RuntimeException describeException =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.describeTable(describeTableRequest));
    assertLanceErrorCode(describeException, ErrorCode.TABLE_NOT_FOUND);
  }

  @Test
  void testTableExists() {
    catalog = createCatalog(CATALOG_NAME);
    createSchema();

    List<String> ids = List.of(CATALOG_NAME, SCHEMA_NAME, "table_exists");
    DeclareTableRequest declareTableRequest = new DeclareTableRequest();
    String location = tempDir + "/" + "table_exists/";
    declareTableRequest.setLocation(location);
    declareTableRequest.setId(ids);
    DeclareTableResponse response =
        Assertions.assertDoesNotThrow(() -> ns.declareTable(declareTableRequest));
    Assertions.assertNotNull(response);
    Assertions.assertEquals(location, response.getLocation());

    // Test existing table
    TableExistsRequest tableExistsReq = new TableExistsRequest();
    tableExistsReq.setId(ids);
    Assertions.assertDoesNotThrow(() -> ns.tableExists(tableExistsReq));

    // Test non-existing table
    List<String> nonExistingIds = List.of(CATALOG_NAME, SCHEMA_NAME, "non_existing_table");
    tableExistsReq.setId(nonExistingIds);
    RuntimeException exception =
        Assertions.assertThrows(RuntimeException.class, () -> ns.tableExists(tableExistsReq));
    assertLanceErrorCode(exception, ErrorCode.TABLE_NOT_FOUND);
    Assertions.assertTrue(exception.getMessage().contains("Table not found"));
  }

  @Test
  void testDropTable() {
    catalog = createCatalog(CATALOG_NAME);
    createSchema();

    List<String> ids = List.of(CATALOG_NAME, SCHEMA_NAME, "table_to_drop");
    DeclareTableRequest declareTableRequest = new DeclareTableRequest();
    String location = tempDir + "/" + "table_to_drop/";
    declareTableRequest.setLocation(location);
    declareTableRequest.setId(ids);
    DeclareTableResponse response =
        Assertions.assertDoesNotThrow(() -> ns.declareTable(declareTableRequest));
    Assertions.assertNotNull(response);
    Assertions.assertEquals(location, response.getLocation());

    // Drop the table
    DropTableRequest dropTableRequest = new DropTableRequest();
    dropTableRequest.setId(ids);
    Assertions.assertDoesNotThrow(() -> ns.dropTable(dropTableRequest));

    // Describe the dropped table should fail
    DescribeTableRequest describeTableRequest = new DescribeTableRequest();
    describeTableRequest.setId(ids);
    RuntimeException exception =
        Assertions.assertThrows(
            RuntimeException.class, () -> ns.describeTable(describeTableRequest));
    assertLanceErrorCode(exception, ErrorCode.TABLE_NOT_FOUND);

    // Drop a non-existing table should fail
    dropTableRequest.setId(ids);
    exception =
        Assertions.assertThrows(RuntimeException.class, () -> ns.dropTable(dropTableRequest));
    assertLanceErrorCode(exception, ErrorCode.TABLE_NOT_FOUND);
  }

  @Test
  void testDeclareTable() {
    catalog = createCatalog(CATALOG_NAME);
    createSchema();

    DeclareTableRequest request = new DeclareTableRequest();
    String location = tempDir + "/" + "declared_table/";
    request.setLocation(location);
    request.setId(List.of(CATALOG_NAME, SCHEMA_NAME, "declared_table"));

    DeclareTableResponse response = ns.declareTable(request);
    Assertions.assertNotNull(response);
    Assertions.assertEquals(location, response.getLocation());

    DescribeTableRequest describeTableRequest = new DescribeTableRequest();
    describeTableRequest.setId(List.of(CATALOG_NAME, SCHEMA_NAME, "declared_table"));

    DescribeTableResponse loadTable = ns.describeTable(describeTableRequest);
    Assertions.assertNotNull(loadTable);
    Assertions.assertEquals(location, loadTable.getLocation());
    Assertions.assertEquals(
        "true", loadTable.getMetadata().get(LanceConstants.LANCE_TABLE_DECLARED));
    Assertions.assertEquals("true", loadTable.getMetadata().get(Table.PROPERTY_EXTERNAL));

    // Try to declare the same table again should fail
    RuntimeException declareException =
        Assertions.assertThrows(
            RuntimeException.class,
            () -> {
              ns.declareTable(request);
            });
    assertLanceErrorCode(declareException, ErrorCode.TABLE_ALREADY_EXISTS);

    // Declare a table with non-existent location should succeed
    // since storage is not touched
    DeclareTableRequest anotherRequest = new DeclareTableRequest();
    anotherRequest.setId(List.of(CATALOG_NAME, SCHEMA_NAME, "another_declared_table"));
    String anotherLocation = tempDir + "/" + "another_declared_location/";
    Assertions.assertFalse(new File(anotherLocation).exists());
    anotherRequest.setLocation(anotherLocation);
    DeclareTableResponse anotherResponse = ns.declareTable(anotherRequest);
    Assertions.assertNotNull(anotherResponse);
    Assertions.assertEquals(anotherLocation, anotherResponse.getLocation());
    // Will not touch storage, so the path should not be created.
    Assertions.assertFalse(new File(anotherLocation).exists());
  }

  private CreateTableResponse createTable(
      List<String> ids,
      String location,
      Map<String, String> tableProperties,
      byte[] body,
      String mode) {
    Map<String, String> additionalHeaders = Maps.newHashMap();
    if (location != null) {
      additionalHeaders.put(LanceConstants.LANCE_TABLE_LOCATION_HEADER, location);
    }
    if (tableProperties != null && !tableProperties.isEmpty()) {
      additionalHeaders.put(
          LanceConstants.LANCE_TABLE_PROPERTIES_PREFIX_HEADER,
          serializeTableProperties(tableProperties));
    }

    try {
      return createTableApi()
          .createTable(
              String.join(DELIMITER, ids), body, DELIMITER, mode, null, null, additionalHeaders);
    } catch (ApiException e) {
      throw toLanceNamespaceException(e);
    }
  }

  private static String serializeTableProperties(Map<String, String> tableProperties) {
    try {
      return JsonUtils.anyFieldMapper().writeValueAsString(tableProperties);
    } catch (IOException e) {
      throw new IllegalStateException("Failed to serialize table properties", e);
    }
  }

  private static LanceNamespaceException toLanceNamespaceException(ApiException e) {
    if (e.getResponseBody() != null) {
      try {
        ErrorResponse errorResponse =
            JsonUtils.anyFieldMapper().readValue(e.getResponseBody(), ErrorResponse.class);
        ErrorCode errorCode =
            errorResponse.getCode() == null
                ? ErrorCode.INTERNAL
                : ErrorCode.fromCode(errorResponse.getCode());
        return new LanceNamespaceException(
            errorCode,
            errorResponse.getError(),
            errorResponse.getDetail(),
            errorResponse.getInstance(),
            e);
      } catch (IOException ignored) {
        // Fall through to a generic internal error.
      }
    }

    return new LanceNamespaceException(ErrorCode.INTERNAL, e.getMessage(), e);
  }

  private static void assertLanceErrorCode(
      RuntimeException exception, ErrorCode expectedErrorCode) {
    Assertions.assertInstanceOf(LanceNamespaceException.class, exception);
    Assertions.assertEquals(
        expectedErrorCode.getCode(), ((LanceNamespaceException) exception).getCode());
  }

  private TableApi createTableApi() {
    ApiClient apiClient = new ApiClient().setBasePath(getLanceRestServiceUrl());
    return new TableApi(apiClient);
  }

  @Test
  void testDropTableWhenLocationMissingOrNotCreated() {
    catalog = createCatalog(CATALOG_NAME);
    createSchema();

    // Create table via register with non-existent location
    List<String> registerIds = List.of(CATALOG_NAME, SCHEMA_NAME, "register_missing_location");
    String missingLocation = tempDir + "/" + "register_missing_location/";
    RegisterTableRequest registerTableRequest = new RegisterTableRequest();
    registerTableRequest.setId(registerIds);
    registerTableRequest.setLocation(missingLocation);
    registerTableRequest.setMode("create");
    RegisterTableResponse registerTableResponse =
        Assertions.assertDoesNotThrow(() -> ns.registerTable(registerTableRequest));
    Assertions.assertEquals(missingLocation, registerTableResponse.getLocation());
    Assertions.assertFalse(new File(missingLocation).exists());

    DropTableRequest dropRegisteredTable = new DropTableRequest();
    dropRegisteredTable.setId(registerIds);
    Assertions.assertDoesNotThrow(() -> ns.dropTable(dropRegisteredTable));
    DescribeTableRequest describeRegistered = new DescribeTableRequest();
    describeRegistered.setId(registerIds);
    Assertions.assertThrows(
        LanceNamespaceException.class, () -> ns.describeTable(describeRegistered));

    // Register table with an existing location, then delete the location before drop
    List<String> existingIds = List.of(CATALOG_NAME, SCHEMA_NAME, "existing_then_deleted");
    String existingLocation = tempDir + "/" + "existing_then_deleted/";
    new File(existingLocation).mkdirs();
    Assertions.assertTrue(new File(existingLocation).exists());

    RegisterTableRequest existingRegisterRequest = new RegisterTableRequest();
    existingRegisterRequest.setId(existingIds);
    existingRegisterRequest.setLocation(existingLocation);
    existingRegisterRequest.setMode("create");
    Assertions.assertDoesNotThrow(() -> ns.registerTable(existingRegisterRequest));

    FileUtils.deleteQuietly(new File(existingLocation));
    Assertions.assertFalse(new File(existingLocation).exists());

    DropTableRequest dropExistingTable = new DropTableRequest();
    dropExistingTable.setId(existingIds);
    Assertions.assertDoesNotThrow(() -> ns.dropTable(dropExistingTable));
    DescribeTableRequest describeExisting = new DescribeTableRequest();
    describeExisting.setId(existingIds);
    Assertions.assertThrows(
        LanceNamespaceException.class, () -> ns.describeTable(describeExisting));
  }

  private GravitinoMetalake createMetalake(String metalakeName) {
    return client.createMetalake(metalakeName, "metalake for lance rest service tests", null);
  }

  private Catalog createCatalog(String catalogName) {
    Map<String, String> catalogProperties =
        ImmutableMap.<String, String>builder()
            .putAll(properties)
            .put("lance.storage.endpoint", MINIO_ENDPOINT)
            .put("lance.storage.allow_http", "true")
            .put("lance.storage.access_key_id", MINIO_ACCESS_KEY)
            .put("lance.storage.secret_access_key", MINIO_SECRET_KEY)
            .put("lance.storage.region", MINIO_REGION)
            .build();

    return metalake.createCatalog(
        catalogName,
        Catalog.Type.RELATIONAL,
        "lakehouse-generic",
        "catalog for lance rest service tests",
        catalogProperties);
  }

  private void createSchema() {
    Map<String, String> schemaProperties = Maps.newHashMap();
    String comment = "comment";
    catalog.asSchemas().createSchema(SCHEMA_NAME, comment, schemaProperties);
    catalog.asSchemas().loadSchema(SCHEMA_NAME);
  }

  private String getLanceRestServiceUrl() {
    return String.format("http://%s:%d/lance", "localhost", getLanceRESTServerPort());
  }

  private Exception appendFailure(Exception failure, Exception candidate) {
    if (failure == null) {
      return candidate;
    }

    failure.addSuppressed(candidate);
    return failure;
  }
}
