/*
 * 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.catalog;

import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
import static org.apache.gravitino.Entity.EntityType.SCHEMA;
import static org.apache.gravitino.Entity.EntityType.VIEW;
import static org.apache.gravitino.StringIdentifier.ID_KEY;
import static org.apache.gravitino.TestBasePropertiesMetadata.COMMENT_KEY;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;

import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Config;
import org.apache.gravitino.Entity;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.TestCatalog;
import org.apache.gravitino.connector.TestCatalogOperations;
import org.apache.gravitino.exceptions.NoSuchEntityException;
import org.apache.gravitino.exceptions.NoSuchViewException;
import org.apache.gravitino.lock.LockManager;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.SchemaEntity;
import org.apache.gravitino.meta.ViewEntity;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.Representation;
import org.apache.gravitino.rel.SQLRepresentation;
import org.apache.gravitino.rel.View;
import org.apache.gravitino.rel.ViewChange;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

public class TestViewOperationDispatcher extends TestOperationDispatcher {
  static ViewOperationDispatcher viewOperationDispatcher;
  static SchemaOperationDispatcher schemaOperationDispatcher;

  @BeforeAll
  public static void initialize() throws IOException, IllegalAccessException {
    schemaOperationDispatcher =
        new SchemaOperationDispatcher(catalogManager, entityStore, idGenerator);
    viewOperationDispatcher =
        new ViewOperationDispatcher(
            catalogManager, entityStore, idGenerator, () -> schemaOperationDispatcher);

    Config config = mock(Config.class);
    doReturn(100000L).when(config).get(TREE_LOCK_MAX_NODE_IN_MEMORY);
    doReturn(1000L).when(config).get(TREE_LOCK_MIN_NODE_IN_MEMORY);
    doReturn(36000L).when(config).get(TREE_LOCK_CLEAN_INTERVAL);
    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", new LockManager(config), true);
    FieldUtils.writeField(
        GravitinoEnv.getInstance(), "internalSchemaDispatcher", schemaOperationDispatcher, true);
  }

  public static ViewOperationDispatcher getViewOperationDispatcher() {
    return viewOperationDispatcher;
  }

  public static SchemaOperationDispatcher getSchemaOperationDispatcher() {
    return schemaOperationDispatcher;
  }

  public static CatalogManager getCatalogManager() {
    return catalogManager;
  }

  /**
   * Helper method to create a mock View object.
   *
   * @param name The name of the view
   * @param props The properties of the view
   * @param auditInfo The audit info of the view
   * @return A mock View implementation
   */
  private static View createMockView(String name, Map<String, String> props, AuditInfo auditInfo) {
    return new View() {
      @Override
      public String name() {
        return name;
      }

      @Override
      public Column[] columns() {
        return new Column[0];
      }

      @Override
      public Representation[] representations() {
        return new Representation[] {
          SQLRepresentation.builder().withDialect("unknown").withSql("SELECT 1").build()
        };
      }

      @Override
      public Map<String, String> properties() {
        return props;
      }

      @Override
      public AuditInfo auditInfo() {
        return auditInfo;
      }
    };
  }

  @Test
  public void testLoadView() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema61");
    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
    schemaOperationDispatcher.createSchema(NameIdentifier.of(viewNs.levels()), "comment", props);

    NameIdentifier viewIdent1 = NameIdentifier.of(viewNs, "view1");

    // Create a mock view through the catalog operations
    AuditInfo auditInfo =
        AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
    View mockView = createMockView("view1", props, auditInfo);

    // Mock the catalog operations to return the view
    TestCatalog testCatalog =
        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, catalog));
    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) testCatalog.ops();
    testCatalogOperations.views.put(viewIdent1, mockView);

    // Test load view
    View loadedView = viewOperationDispatcher.loadView(viewIdent1);
    Assertions.assertEquals("view1", loadedView.name());
    Assertions.assertEquals("test", loadedView.auditInfo().creator());

    // Test load non-existent view
    NameIdentifier viewIdent2 = NameIdentifier.of(viewNs, "non_existent_view");
    Assertions.assertThrows(
        NoSuchViewException.class, () -> viewOperationDispatcher.loadView(viewIdent2));
  }

  @Test
  public void testLoadViewWithInvalidNamespace() {
    Namespace invalidNs = Namespace.of(metalake, catalog, "non_existent_schema");
    NameIdentifier viewIdent = NameIdentifier.of(invalidNs, "view1");

    Assertions.assertThrows(
        NoSuchViewException.class, () -> viewOperationDispatcher.loadView(viewIdent));
  }

  @Test
  public void testLoadViewWithMultipleViews() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema62");
    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
    schemaOperationDispatcher.createSchema(NameIdentifier.of(viewNs.levels()), "comment", props);

    // Create multiple views
    TestCatalog testCatalog =
        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, catalog));
    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) testCatalog.ops();

    for (int i = 1; i <= 3; i++) {
      NameIdentifier viewIdent = NameIdentifier.of(viewNs, "view" + i);
      AuditInfo auditInfo =
          AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
      View mockView = createMockView("view" + i, props, auditInfo);
      testCatalogOperations.views.put(viewIdent, mockView);
    }

    // Test loading each view
    for (int i = 1; i <= 3; i++) {
      NameIdentifier viewIdent = NameIdentifier.of(viewNs, "view" + i);
      View loadedView = viewOperationDispatcher.loadView(viewIdent);
      Assertions.assertEquals("view" + i, loadedView.name());
    }
  }

  @Test
  public void testViewOperationDispatcherRejectsNullSchemaDispatcherSupplier() {
    Assertions.assertThrows(
        NullPointerException.class,
        () -> new ViewOperationDispatcher(catalogManager, entityStore, idGenerator, null));
  }

  @Test
  public void testCreateViewFailsFastWhenSchemaDispatcherSupplierReturnsNull() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema-null-view-dispatcher");
    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
    schemaOperationDispatcher.createSchema(NameIdentifier.of(viewNs.levels()), "comment", props);

    Supplier<SchemaDispatcher> nullSchemaDispatcherSupplier = () -> null;
    ViewOperationDispatcher dispatcher =
        new ViewOperationDispatcher(
            catalogManager, entityStore, idGenerator, nullSchemaDispatcherSupplier);
    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "view_null_dispatcher");
    Representation[] representations =
        new Representation[] {
          SQLRepresentation.builder().withDialect("spark-sql").withSql("SELECT 1").build()
        };

    IllegalArgumentException exception =
        Assertions.assertThrows(
            IllegalArgumentException.class,
            () ->
                dispatcher.createView(
                    viewIdent, "comment", new Column[0], representations, null, null, props));
    Assertions.assertEquals(
        "schemaDispatcherSupplier returned null. "
            + "SchemaDispatcher must be available for view operations.",
        exception.getMessage());
  }

  @Test
  public void testLoadViewAutoImportsIntoEntityStore() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema63");
    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
    schemaOperationDispatcher.createSchema(NameIdentifier.of(viewNs.levels()), "comment", props);

    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "auto_import_view");

    // Create a mock view through the catalog operations
    AuditInfo auditInfo =
        AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
    View mockView = createMockView("auto_import_view", props, auditInfo);

    TestCatalog testCatalog =
        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, catalog));
    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) testCatalog.ops();
    testCatalogOperations.views.put(viewIdent, mockView);

    // Verify view is not in entity store initially
    Assertions.assertThrows(
        NoSuchEntityException.class, () -> entityStore.get(viewIdent, VIEW, ViewEntity.class));

    // Load view - should auto-import
    View loadedView = viewOperationDispatcher.loadView(viewIdent);
    Assertions.assertEquals("auto_import_view", loadedView.name());

    // Verify view was auto-imported into entity store
    ViewEntity viewEntity = entityStore.get(viewIdent, VIEW, ViewEntity.class);
    Assertions.assertNotNull(viewEntity);
    Assertions.assertEquals(viewIdent.name(), viewEntity.name());
    Assertions.assertEquals(viewIdent.namespace(), viewEntity.namespace());
    Assertions.assertEquals(Entity.EntityType.VIEW, viewEntity.type());
  }

  @Test
  public void testLoadViewSkipsImportWhenAlreadyInEntityStore() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema64");
    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
    schemaOperationDispatcher.createSchema(NameIdentifier.of(viewNs.levels()), "comment", props);

    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "already_imported_view");

    // Pre-populate entity store with view entity
    ViewEntity viewEntity =
        ViewEntity.builder()
            .withId(1L)
            .withName(viewIdent.name())
            .withNamespace(viewIdent.namespace())
            .withColumns(new Column[0])
            .withRepresentations(
                new Representation[] {
                  SQLRepresentation.builder().withDialect("unknown").withSql("SELECT 1").build()
                })
            .withAuditInfo(AuditInfo.EMPTY)
            .build();
    entityStore.put(viewEntity, false);

    // Create a mock view through the catalog operations
    AuditInfo auditInfo =
        AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
    View mockView = createMockView("already_imported_view", props, auditInfo);

    TestCatalog testCatalog =
        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, catalog));
    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) testCatalog.ops();
    testCatalogOperations.views.put(viewIdent, mockView);

    // Record the initial entity state
    ViewEntity initialEntity = entityStore.get(viewIdent, VIEW, ViewEntity.class);
    long initialId = initialEntity.id();

    // Load view - should load from catalog but skip import since already in entity store
    View loadedView = viewOperationDispatcher.loadView(viewIdent);
    Assertions.assertEquals("already_imported_view", loadedView.name());

    // Verify entity is still in store with same ID (no duplicate import)
    // If import was called, a new entity would be created with a different ID
    ViewEntity retrievedEntity = entityStore.get(viewIdent, VIEW, ViewEntity.class);
    Assertions.assertNotNull(retrievedEntity);
    Assertions.assertEquals(initialId, retrievedEntity.id(), "Entity ID should not change");
    Assertions.assertEquals(1L, retrievedEntity.id(), "Entity ID should remain 1L (no new import)");
  }

  @Test
  public void testLoadViewAutoImportWithMultipleConcurrentLoads() throws Exception {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema66");
    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
    schemaOperationDispatcher.createSchema(NameIdentifier.of(viewNs.levels()), "comment", props);

    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "concurrent_view");

    // Create a mock view through the catalog operations
    AuditInfo auditInfo =
        AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
    View mockView = createMockView("concurrent_view", props, auditInfo);

    TestCatalog testCatalog =
        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, catalog));
    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) testCatalog.ops();
    testCatalogOperations.views.put(viewIdent, mockView);

    // Test concurrent loads with multiple threads
    int threadCount = 10;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);
    List<Future<View>> futures = new ArrayList<>();

    try {
      // Submit concurrent load tasks
      for (int i = 0; i < threadCount; i++) {
        Future<View> future =
            executor.submit(
                () -> {
                  latch.countDown();
                  latch.await(); // Wait for all threads to be ready
                  return viewOperationDispatcher.loadView(viewIdent);
                });
        futures.add(future);
      }

      // Verify all concurrent loads succeeded and reference the same imported entity
      // If import was called multiple times, entity IDs would differ across views
      Long entityId = null;
      for (Future<View> future : futures) {
        View loadedView = future.get(5, TimeUnit.SECONDS);
        Assertions.assertEquals("concurrent_view", loadedView.name());

        EntityCombinedView combinedView = (EntityCombinedView) loadedView;
        long currentEntityId = combinedView.viewEntity().id();

        if (entityId == null) {
          entityId = currentEntityId;
        } else {
          Assertions.assertEquals(
              entityId,
              currentEntityId,
              "All concurrent loads should reference the same entity (import called only once)");
        }
      }

    } finally {
      executor.shutdown();
      executor.awaitTermination(10, TimeUnit.SECONDS);
    }
  }

  @Test
  public void testLoadViewAfterManualDelete() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema67");
    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
    schemaOperationDispatcher.createSchema(NameIdentifier.of(viewNs.levels()), "comment", props);

    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "deleted_view");

    // Create a mock view through the catalog operations
    AuditInfo auditInfo =
        AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
    View mockView = createMockView("deleted_view", props, auditInfo);

    TestCatalog testCatalog =
        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, catalog));
    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) testCatalog.ops();
    testCatalogOperations.views.put(viewIdent, mockView);

    // Load view - should auto-import
    View loadedView1 = viewOperationDispatcher.loadView(viewIdent);
    Assertions.assertEquals("deleted_view", loadedView1.name());

    // Manually delete from entity store
    entityStore.delete(viewIdent, VIEW);

    // Load view again - should re-import
    View loadedView2 = viewOperationDispatcher.loadView(viewIdent);
    Assertions.assertEquals("deleted_view", loadedView2.name());

    // Verify view was re-imported
    ViewEntity viewEntity = entityStore.get(viewIdent, VIEW, ViewEntity.class);
    Assertions.assertNotNull(viewEntity);
  }

  @Test
  public void testCreateView() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema_create_view");
    Map<String, String> schemaProps = ImmutableMap.of("k1", "v1", "k2", "v2");
    schemaOperationDispatcher.createSchema(
        NameIdentifier.of(viewNs.levels()), "comment", schemaProps);

    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "created_view");
    Representation[] representations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
    };
    Map<String, String> viewProps = ImmutableMap.of("k1", "v1", "p1", "pv1");

    View created =
        viewOperationDispatcher.createView(
            viewIdent, "view comment", new Column[0], representations, null, null, viewProps);

    Assertions.assertEquals("created_view", created.name());
    Assertions.assertEquals("view comment", created.comment());
    Assertions.assertTrue(created instanceof EntityCombinedView);

    // Entity stored as ViewEntity in entity store.
    ViewEntity stored = entityStore.get(viewIdent, VIEW, ViewEntity.class);
    Assertions.assertNotNull(stored);
    Assertions.assertEquals("view comment", stored.comment());
    Assertions.assertEquals("pv1", stored.properties().get("p1"));

    // Test required view properties exception
    Map<String, String> missingRequired = new HashMap<>();
    missingRequired.put("p1", "pv1");
    testPropertyException(
        () ->
            viewOperationDispatcher.createView(
                NameIdentifier.of(viewNs, "missing_required"),
                "c",
                new Column[0],
                representations,
                null,
                null,
                missingRequired),
        "Properties or property prefixes are required and must be set");

    // Test reserved view properties exception
    Map<String, String> reservedProps = new HashMap<>();
    reservedProps.put("k1", "v1");
    reservedProps.put(COMMENT_KEY, "view comment");
    reservedProps.put(ID_KEY, "gravitino.v1.uidfdsafdsa");
    testPropertyException(
        () ->
            viewOperationDispatcher.createView(
                NameIdentifier.of(viewNs, "reserved_props"),
                "c",
                new Column[0],
                representations,
                null,
                null,
                reservedProps),
        "Properties or property prefixes are reserved and cannot be set",
        "comment",
        "gravitino.identifier");

    Assertions.assertFalse(created.properties().containsKey(ID_KEY));
  }

  @Test
  public void testDropView() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema_drop_view");
    schemaOperationDispatcher.createSchema(
        NameIdentifier.of(viewNs.levels()), "comment", ImmutableMap.of("k1", "v1", "k2", "v2"));

    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "to_drop");
    Representation[] representations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
    };
    viewOperationDispatcher.createView(
        viewIdent, null, new Column[0], representations, null, null, ImmutableMap.of("k1", "v1"));

    Assertions.assertTrue(viewOperationDispatcher.dropView(viewIdent));
    // Entity removed.
    Assertions.assertThrows(
        NoSuchEntityException.class, () -> entityStore.get(viewIdent, VIEW, ViewEntity.class));
    // Dropping again returns false (underlying catalog reports missing).
    Assertions.assertFalse(viewOperationDispatcher.dropView(viewIdent));
  }

  @Test
  public void testDropViewCleansUpAutoDroppedParentSchemas() throws Exception {
    NameIdentifier schemaIdent =
        NameIdentifier.of(metalake, catalog, "dropViewParentA:dropViewParentB");
    NameIdentifier ancestorIdent = NameIdentifier.of(metalake, catalog, "dropViewParentA");
    Namespace viewNs =
        Namespace.of(
            schemaIdent.namespace().level(0), schemaIdent.namespace().level(1), schemaIdent.name());
    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "view_auto_drop_parent");
    Representation[] representations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
    };

    schemaOperationDispatcher.createSchema(
        schemaIdent, "comment", ImmutableMap.of("k1", "v1", "k2", "v2"));
    putSchemaEntity(ancestorIdent);
    viewOperationDispatcher.createView(
        viewIdent, null, new Column[0], representations, null, null, ImmutableMap.of("k1", "v1"));

    TestCatalog testCatalog =
        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, catalog));
    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) testCatalog.ops();
    Assertions.assertTrue(testCatalogOperations.dropSchema(schemaIdent, false));
    Assertions.assertFalse(testCatalogOperations.schemaExists(schemaIdent));
    Assertions.assertFalse(testCatalogOperations.schemaExists(ancestorIdent));

    Assertions.assertTrue(viewOperationDispatcher.dropView(viewIdent));
    Assertions.assertFalse(entityStore.exists(schemaIdent, SCHEMA));
    Assertions.assertFalse(entityStore.exists(ancestorIdent, SCHEMA));
  }

  @Test
  public void testDropMissingViewCleansUpSchemas() throws Exception {
    NameIdentifier schemaIdent =
        NameIdentifier.of(metalake, catalog, "dropViewGoneA:dropViewGoneB");
    NameIdentifier ancestorIdent = NameIdentifier.of(metalake, catalog, "dropViewGoneA");
    Namespace viewNs =
        Namespace.of(
            schemaIdent.namespace().level(0), schemaIdent.namespace().level(1), schemaIdent.name());
    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "view_already_gone");
    Representation[] representations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
    };

    schemaOperationDispatcher.createSchema(
        schemaIdent, "comment", ImmutableMap.of("k1", "v1", "k2", "v2"));
    putSchemaEntity(ancestorIdent);
    viewOperationDispatcher.createView(
        viewIdent, null, new Column[0], representations, null, null, ImmutableMap.of("k1", "v1"));

    // Simulate an out-of-band drop: the backend already removed the view and auto-dropped the
    // now-empty namespaces, so the catalog no longer knows the view (dropView returns false),
    // while Gravitino still holds the orphaned schema entities.
    TestCatalog testCatalog =
        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, catalog));
    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) testCatalog.ops();
    Assertions.assertTrue(testCatalogOperations.dropView(viewIdent));
    Assertions.assertTrue(testCatalogOperations.dropSchema(schemaIdent, false));
    Assertions.assertFalse(testCatalogOperations.schemaExists(schemaIdent));
    Assertions.assertFalse(testCatalogOperations.schemaExists(ancestorIdent));

    // dropView returns false because the view is already gone from the catalog, but the
    // orphaned schema entities must still be cleaned up.
    Assertions.assertFalse(viewOperationDispatcher.dropView(viewIdent));
    Assertions.assertFalse(entityStore.exists(schemaIdent, SCHEMA));
    Assertions.assertFalse(entityStore.exists(ancestorIdent, SCHEMA));
  }

  @Test
  public void testListViews() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema_list_view");
    schemaOperationDispatcher.createSchema(
        NameIdentifier.of(viewNs.levels()), "comment", ImmutableMap.of("k1", "v1", "k2", "v2"));

    Representation[] representations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
    };
    for (int i = 0; i < 3; i++) {
      viewOperationDispatcher.createView(
          NameIdentifier.of(viewNs, "lv" + i),
          null,
          new Column[0],
          representations,
          null,
          null,
          ImmutableMap.of("k1", "v1"));
    }

    NameIdentifier[] listed = viewOperationDispatcher.listViews(viewNs);
    Assertions.assertEquals(3, listed.length);
  }

  @Test
  public void testAlterViewProperties() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema_alter_props");
    schemaOperationDispatcher.createSchema(
        NameIdentifier.of(viewNs.levels()), "c", ImmutableMap.of("k1", "v1", "k2", "v2"));

    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "alter_props");
    Representation[] representations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
    };
    viewOperationDispatcher.createView(
        viewIdent, "c", new Column[0], representations, null, null, ImmutableMap.of("k1", "v1"));

    View altered =
        viewOperationDispatcher.alterView(
            viewIdent, ViewChange.setProperty("k2", "v2"), ViewChange.removeProperty("k1"));
    Assertions.assertEquals("v2", altered.properties().get("k2"));
    Assertions.assertFalse(altered.properties().containsKey("k1"));

    ViewEntity stored = entityStore.get(viewIdent, VIEW, ViewEntity.class);
    Assertions.assertEquals("v2", stored.properties().get("k2"));
    Assertions.assertFalse(stored.properties().containsKey("k1"));

    // Test immutable view properties
    ViewChange[] illegalChange =
        new ViewChange[] {ViewChange.setProperty(COMMENT_KEY, "new comment")};
    testPropertyException(
        () -> viewOperationDispatcher.alterView(viewIdent, illegalChange),
        "Property comment is immutable or reserved, cannot be set");
  }

  @Test
  public void testAlterViewRename() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema_alter_rename");
    schemaOperationDispatcher.createSchema(
        NameIdentifier.of(viewNs.levels()), "c", ImmutableMap.of("k1", "v1", "k2", "v2"));

    NameIdentifier oldIdent = NameIdentifier.of(viewNs, "old_view");
    Representation[] representations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
    };
    viewOperationDispatcher.createView(
        oldIdent, "c", new Column[0], representations, null, null, ImmutableMap.of("k1", "v1"));

    View altered = viewOperationDispatcher.alterView(oldIdent, ViewChange.rename("new_view"));
    Assertions.assertEquals("new_view", altered.name());
  }

  @Test
  public void testAlterViewReplace() throws IOException {
    Namespace viewNs = Namespace.of(metalake, catalog, "schema_alter_replace");
    schemaOperationDispatcher.createSchema(
        NameIdentifier.of(viewNs.levels()), "c", ImmutableMap.of("k1", "v1", "k2", "v2"));

    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "replace");
    Representation[] representations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build()
    };
    viewOperationDispatcher.createView(
        viewIdent, "c", new Column[0], representations, null, null, ImmutableMap.of("k1", "v1"));

    Representation[] newRepresentations = {
      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 1").build(),
      SQLRepresentation.builder().withDialect("trino").withSql("SELECT 2").build()
    };
    View altered =
        viewOperationDispatcher.alterView(
            viewIdent,
            ViewChange.replaceView(
                new Column[0], newRepresentations, "cat1", "sch1", "new comment"));
    Assertions.assertEquals(2, altered.representations().length);
    Assertions.assertEquals("new comment", altered.comment());
    Assertions.assertEquals("cat1", altered.defaultCatalog());
    Assertions.assertEquals("sch1", altered.defaultSchema());

    SQLRepresentation trino =
        (SQLRepresentation)
            Arrays.stream(altered.representations())
                .filter(r -> r instanceof SQLRepresentation)
                .filter(r -> "trino".equals(((SQLRepresentation) r).dialect()))
                .findFirst()
                .orElseThrow(AssertionError::new);
    Assertions.assertEquals("SELECT 2", trino.sql());
  }

  private void putSchemaEntity(NameIdentifier ident) throws IOException {
    SchemaEntity entity =
        SchemaEntity.builder()
            .withId(idGenerator.nextId())
            .withName(ident.name())
            .withNamespace(ident.namespace())
            .withAuditInfo(
                AuditInfo.builder().withCreator("tester").withCreateTime(Instant.now()).build())
            .build();
    entityStore.put(entity, true);
  }
}
