/*
 * 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.ignite.internal.sql.engine.planner;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.util.List;
import java.util.Set;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.core.Filter;
import org.apache.calcite.rel.core.Project;
import org.apache.calcite.rel.logical.LogicalCorrelate;
import org.apache.calcite.rel.logical.LogicalFilter;
import org.apache.calcite.rel.logical.LogicalProject;
import org.apache.calcite.rex.RexCorrelVariable;
import org.apache.calcite.rex.RexFieldAccess;
import org.apache.calcite.sql.SqlNode;
import org.apache.ignite.internal.sql.engine.framework.TestBuilders;
import org.apache.ignite.internal.sql.engine.framework.TestBuilders.TableBuilder;
import org.apache.ignite.internal.sql.engine.prepare.IgnitePlanner;
import org.apache.ignite.internal.sql.engine.prepare.PlannerPhase;
import org.apache.ignite.internal.sql.engine.prepare.PlanningContext;
import org.apache.ignite.internal.sql.engine.rel.IgniteCorrelatedNestedLoopJoin;
import org.apache.ignite.internal.sql.engine.rel.IgniteFilter;
import org.apache.ignite.internal.sql.engine.rel.IgniteRel;
import org.apache.ignite.internal.sql.engine.schema.IgniteSchema;
import org.apache.ignite.internal.sql.engine.schema.IgniteTable;
import org.apache.ignite.internal.sql.engine.trait.IgniteDistributions;
import org.apache.ignite.internal.sql.engine.util.RexUtils;
import org.apache.ignite.internal.type.NativeTypes;
import org.junit.jupiter.api.Test;

/** Tests to verify correlated subquery planning. */
public class CorrelatedSubqueryPlannerTest extends AbstractPlannerTest {
    /**
     * Test verifies the row type is consistent for correlation variable and node that actually puts this variable into a context.
     *
     * <p>In this particular test the row type of the left input of CNLJ node should
     * match the row type correlated variable in the filter was created with.
     *
     * @throws Exception In case of any unexpected error.
     */
    @Test
    public void test() throws Exception {
        IgniteSchema schema = createSchema(createTestTable("A", "B", "C", "D", "E"));

        String sql = ""
                + "SELECT (SELECT count(*) FROM t1 AS x WHERE x.b<t1.b)\n"
                + "  FROM t1\n"
                + " WHERE (a>e-2 AND a<e+2)\n"
                + "    OR c>d\n"
                + " ORDER BY 1;";

        IgniteRel rel = physicalPlan(sql, schema, "FilterTableScanMergeRule");

        IgniteFilter filter = findFirstNode(rel, byClass(IgniteFilter.class)
                .and(f -> RexUtils.hasCorrelation(((Filter) f).getCondition())));

        RexFieldAccess fieldAccess = findFirst(filter.getCondition(), RexFieldAccess.class);

        assertNotNull(fieldAccess);
        assertEquals(
                RexCorrelVariable.class,
                fieldAccess.getReferenceExpr().getClass()
        );

        IgniteCorrelatedNestedLoopJoin join = findFirstNode(rel, byClass(IgniteCorrelatedNestedLoopJoin.class));

        assertEquals(
                fieldAccess.getReferenceExpr().getType(),
                join.getLeft().getRowType()
        );
    }

    /**
     * Test verifies resolving of collisions in the left hand of correlates.
     */
    @Test
    public void testCorrelatesCollisionsLeftHand() throws Exception {
        IgniteSchema schema = createSchema(
                createTestTable("A", "B", "C", "D")
        );

        String sql = "SELECT * FROM t1 as cor WHERE "
                + "EXISTS (SELECT 1 FROM t1 WHERE t1.b = cor.a) AND "
                + "EXISTS (SELECT 1 FROM t1 WHERE t1.c = cor.a) AND "
                + "EXISTS (SELECT 1 FROM t1 WHERE t1.d = cor.a)";

        PlanningContext ctx = plannerCtx(sql, schema);

        try (IgnitePlanner planner = ctx.planner()) {
            RelNode rel = convertSubQueries(planner, ctx);

            List<LogicalCorrelate> correlates = findNodes(rel, byClass(LogicalCorrelate.class));

            assertEquals(3, correlates.size());

            // There are collisions by correlation id.
            assertEquals(correlates.get(0).getCorrelationId(), correlates.get(1).getCorrelationId());
            assertEquals(correlates.get(0).getCorrelationId(), correlates.get(2).getCorrelationId());

            rel = planner.replaceCorrelatesCollisions(rel);

            correlates = findNodes(rel, byClass(LogicalCorrelate.class));

            assertEquals(3, correlates.size());

            // There are no collisions by correlation id.
            assertNotEquals(correlates.get(0).getCorrelationId(), correlates.get(1).getCorrelationId());
            assertNotEquals(correlates.get(0).getCorrelationId(), correlates.get(2).getCorrelationId());
            assertNotEquals(correlates.get(1).getCorrelationId(), correlates.get(2).getCorrelationId());

            List<LogicalFilter> filters = findNodes(rel, byClass(LogicalFilter.class)
                    .and(f -> RexUtils.hasCorrelation(((Filter) f).getCondition())));

            assertEquals(3, filters.size());

            // Filters match correlates in reverse order (we find outer correlate first, but inner filter first).
            assertEquals(Set.of(correlates.get(0).getCorrelationId()),
                    RexUtils.extractCorrelationIds(filters.get(2).getCondition()));

            assertEquals(Set.of(correlates.get(1).getCorrelationId()),
                    RexUtils.extractCorrelationIds(filters.get(1).getCondition()));

            assertEquals(Set.of(correlates.get(2).getCorrelationId()),
                    RexUtils.extractCorrelationIds(filters.get(0).getCondition()));
        }
    }

    /**
     * Test verifies resolving of collisions in the right hand of correlates.
     */
    @Test
    public void testCorrelatesCollisionsRightHand() throws Exception {
        IgniteSchema schema = createSchema(
                createTestTable("A")
        );

        String sql = "SELECT (SELECT (SELECT (SELECT cor.a))) FROM t1 as cor";

        PlanningContext ctx = plannerCtx(sql, schema);

        try (IgnitePlanner planner = ctx.planner()) {
            RelNode rel = convertSubQueries(planner, ctx);

            List<LogicalCorrelate> correlates = findNodes(rel, byClass(LogicalCorrelate.class));

            assertEquals(3, correlates.size());

            // There are collisions by correlation id.
            assertEquals(correlates.get(0).getCorrelationId(), correlates.get(1).getCorrelationId());
            assertEquals(correlates.get(0).getCorrelationId(), correlates.get(2).getCorrelationId());

            rel = planner.replaceCorrelatesCollisions(rel);

            correlates = findNodes(rel, byClass(LogicalCorrelate.class));

            assertEquals(1, correlates.size());
        }
    }

    /**
     * Test verifies resolving of collisions in right and left hands of correlates.
     */
    @Test
    public void testCorrelatesCollisionsMixed() throws Exception {
        IgniteSchema schema = createSchema(
                createTestTable("A", "B", "C")
        );

        String sql = "SELECT * FROM t1 as cor WHERE "
                + "EXISTS (SELECT 1 FROM t1 WHERE t1.b = (SELECT cor.a)) AND "
                + "EXISTS (SELECT 1 FROM t1 WHERE t1.c = (SELECT cor.a))";

        PlanningContext ctx = plannerCtx(sql, schema);

        try (IgnitePlanner planner = ctx.planner()) {
            RelNode rel = convertSubQueries(planner, ctx);

            List<LogicalCorrelate> correlates = findNodes(rel, byClass(LogicalCorrelate.class));

            assertEquals(4, correlates.size());

            // There are collisions by correlation id.
            assertEquals(correlates.get(0).getCorrelationId(), correlates.get(1).getCorrelationId());
            assertEquals(correlates.get(0).getCorrelationId(), correlates.get(2).getCorrelationId());
            assertEquals(correlates.get(0).getCorrelationId(), correlates.get(3).getCorrelationId());

            rel = planner.replaceCorrelatesCollisions(rel);

            correlates = findNodes(rel, byClass(LogicalCorrelate.class));

            assertEquals(2, correlates.size());

            // There are no collisions by correlation id.
            assertNotEquals(correlates.get(0).getCorrelationId(), correlates.get(1).getCorrelationId());

            List<LogicalProject> projects = findNodes(rel, byClass(LogicalProject.class)
                    .and(f -> RexUtils.hasCorrelation(((Project) f).getProjects())));

            assertEquals(2, projects.size());

            assertEquals(Set.of(correlates.get(0).getCorrelationId()),
                    RexUtils.extractCorrelationIds(projects.get(1).getProjects()));

            assertEquals(Set.of(correlates.get(1).getCorrelationId()),
                    RexUtils.extractCorrelationIds(projects.get(0).getProjects()));
        }
    }

    private RelNode convertSubQueries(IgnitePlanner planner, PlanningContext ctx) throws Exception {
        // Parse and validate.
        SqlNode sqlNode = planner.validate(planner.parse(ctx.query()));

        // Create original logical plan.
        RelNode rel = planner.rel(sqlNode).rel;

        // Convert sub-queries to correlates.
        return planner.transform(PlannerPhase.HEP_SUBQUERIES_TO_CORRELATES, rel.getTraitSet(), rel);
    }

    /** Creates test table with columns of given name and INT32 type. */
    static IgniteTable createTestTable(String... columns) {
        assert columns.length > 0;

        TableBuilder tableBuilder = TestBuilders.table()
                .name("T1")
                .distribution(IgniteDistributions.single());

        for (String column : columns) {
            tableBuilder.addColumn(column, NativeTypes.INT32);
        }

        return tableBuilder.build();
    }
}

