/**
 * @license
 * Copyright 2022 Google LLC
 *
 * Licensed 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.
 */

import { expect } from 'chai';

import {
  queryToTarget,
  queryWithAddedFilter,
  queryWithAddedOrderBy,
  Query,
  newQueryForCollectionGroup
} from '../../../src/core/query';
import { IndexKind } from '../../../src/model/field_index';
import { TargetIndexMatcher } from '../../../src/model/target_index_matcher';
import { fieldIndex, filter, orderBy, query } from '../../util/helpers';

describe('Target Bounds', () => {
  const queriesWithEqualities = [
    queryWithAddedFilter(query('collId'), filter('a', '==', 'a')),
    queryWithAddedFilter(query('collId'), filter('a', 'in', ['a']))
  ];

  const queriesWithInequalities = [
    queryWithAddedFilter(query('collId'), filter('a', '<', 'a')),
    queryWithAddedFilter(query('collId'), filter('a', '<=', 'a')),
    queryWithAddedFilter(query('collId'), filter('a', '>=', 'a')),
    queryWithAddedFilter(query('collId'), filter('a', '>', 'a')),
    queryWithAddedFilter(query('collId'), filter('a', '!=', 'a')),
    queryWithAddedFilter(query('collId'), filter('a', 'not-in', ['a']))
  ];

  const queriesWithArrayContains = [
    queryWithAddedFilter(query('collId'), filter('a', 'array-contains', 'a')),
    queryWithAddedFilter(
      query('collId'),
      filter('a', 'array-contains-any', ['a'])
    )
  ];

  it('can use merge join', () => {
    let q = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a', '==', 1)),
      filter('b', '==', 2)
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
    validateServesTarget(q, 'b', IndexKind.ASCENDING);

    q = queryWithAddedOrderBy(
      queryWithAddedFilter(
        queryWithAddedFilter(query('collId'), filter('a', '==', 1)),
        filter('b', '==', 2)
      ),
      orderBy('__name__', 'desc')
    );
    validateServesTarget(
      q,
      'a',
      IndexKind.ASCENDING,
      '__name__',
      IndexKind.DESCENDING
    );
    validateServesTarget(
      q,
      'b',
      IndexKind.ASCENDING,
      '__name__',
      IndexKind.DESCENDING
    );
  });

  it('can use partial index', () => {
    let q = queryWithAddedOrderBy(query('collId'), orderBy('a'));
    validateServesTarget(q, 'a', IndexKind.ASCENDING);

    q = queryWithAddedOrderBy(
      queryWithAddedOrderBy(query('collId'), orderBy('a')),
      orderBy('b')
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
    validateServesTarget(q, 'a', IndexKind.ASCENDING, 'b', IndexKind.ASCENDING);
  });

  it('cannot use overspecified index', () => {
    const q = queryWithAddedOrderBy(query('collId'), orderBy('a'));
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
    validateDoesNotServeTarget(
      q,
      'a',
      IndexKind.ASCENDING,
      'b',
      IndexKind.ASCENDING
    );
  });

  it('equalities with default order', () => {
    for (const query of queriesWithEqualities) {
      validateServesTarget(query, 'a', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'b', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'a', IndexKind.CONTAINS);
    }
  });

  it('equalities with ascending order', () => {
    const queriesWithEqualitiesAndAscendingOrder = queriesWithEqualities.map(
      q => queryWithAddedOrderBy(q, orderBy('a', 'asc'))
    );

    for (const query of queriesWithEqualitiesAndAscendingOrder) {
      validateServesTarget(query, 'a', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'b', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'a', IndexKind.CONTAINS);
    }
  });

  it('equalities with descending order', () => {
    const queriesWithEqualitiesAndDescendingOrder = queriesWithEqualities.map(
      q => queryWithAddedOrderBy(q, orderBy('a', 'desc'))
    );

    for (const query of queriesWithEqualitiesAndDescendingOrder) {
      validateServesTarget(query, 'a', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'b', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'a', IndexKind.CONTAINS);
    }
  });

  it('inequalities with default order', () => {
    for (const query of queriesWithInequalities) {
      validateServesTarget(query, 'a', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'b', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'a', IndexKind.CONTAINS);
    }
  });

  it('inequalities with ascending order', () => {
    const queriesWithInequalitiesAndAscendingOrder =
      queriesWithInequalities.map(q =>
        queryWithAddedOrderBy(q, orderBy('a', 'asc'))
      );

    for (const query of queriesWithInequalitiesAndAscendingOrder) {
      validateServesTarget(query, 'a', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'b', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'a', IndexKind.CONTAINS);
    }
  });

  it('inequalities with descending order', () => {
    const queriesWithInequalitiesAndDescendingOrder =
      queriesWithInequalities.map(q =>
        queryWithAddedOrderBy(q, orderBy('a', 'desc'))
      );

    for (const query of queriesWithInequalitiesAndDescendingOrder) {
      validateServesTarget(query, 'a', IndexKind.DESCENDING);
      validateDoesNotServeTarget(query, 'b', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'a', IndexKind.CONTAINS);
    }
  });

  it('inequality uses single field index', () => {
    const q = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a', '>', 1)),
      filter('a', '<', 10)
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
  });

  it('is query uses merge join', () => {
    const q = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a', 'in', [1, 2])),
      filter('b', '==', 5)
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
    validateServesTarget(q, 'b', IndexKind.ASCENDING);
    validateServesTarget(q, 'a', IndexKind.ASCENDING, 'b', IndexKind.ASCENDING);
  });

  it('validates collection', () => {
    {
      const targetIndexMatcher = new TargetIndexMatcher(
        queryToTarget(query('collId'))
      );
      const index = fieldIndex('collId');
      expect(() => targetIndexMatcher.servedByIndex(index)).to.not.throw;
    }

    {
      const targetIndexMatcher = new TargetIndexMatcher(
        queryToTarget(newQueryForCollectionGroup('collId'))
      );
      const index = fieldIndex('collId');
      expect(() => targetIndexMatcher.servedByIndex(index)).to.not.throw;
    }

    {
      const targetIndexMatcher = new TargetIndexMatcher(
        queryToTarget(query('collId2'))
      );
      const index = fieldIndex('collId');
      expect(() => targetIndexMatcher.servedByIndex(index)).to.throw(
        'Collection IDs do not match'
      );
    }
  });

  it('with array-contains', () => {
    for (const query of queriesWithArrayContains) {
      validateDoesNotServeTarget(query, 'a', IndexKind.ASCENDING);
      validateDoesNotServeTarget(query, 'a', IndexKind.ASCENDING);
      validateServesTarget(query, 'a', IndexKind.CONTAINS);
    }
  });

  it('array-contains is independent', () => {
    const q = queryWithAddedOrderBy(
      queryWithAddedFilter(
        query('collId'),
        filter('value', 'array-contains', 'foo')
      ),
      orderBy('value')
    );
    validateServesTarget(
      q,
      'value',
      IndexKind.CONTAINS,
      'value',
      IndexKind.ASCENDING
    );
    validateServesTarget(
      q,
      'value',
      IndexKind.ASCENDING,
      'value',
      IndexKind.CONTAINS
    );
  });

  it('with array-contains and order by', () => {
    const queriesMultipleFilters = queryWithAddedOrderBy(
      queryWithAddedFilter(
        queryWithAddedFilter(
          query('collId'),
          filter('a', 'array-contains', 'a')
        ),
        filter('a', '>', 'b')
      ),
      orderBy('a', 'asc')
    );
    validateServesTarget(
      queriesMultipleFilters,
      'a',
      IndexKind.CONTAINS,
      'a',
      IndexKind.ASCENDING
    );
  });

  it('with equality and descending order', () => {
    const q = queryWithAddedOrderBy(
      queryWithAddedFilter(query('collId'), filter('a', '==', 1)),
      orderBy('__name__', 'desc')
    );
    validateServesTarget(
      q,
      'a',
      IndexKind.ASCENDING,
      '__name__',
      IndexKind.DESCENDING
    );
  });

  it('with multiple equalities', () => {
    const queriesMultipleFilters = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a1', '==', 'a')),
      filter('a2', '==', 'b')
    );
    validateServesTarget(
      queriesMultipleFilters,
      'a1',
      IndexKind.ASCENDING,
      'a2',
      IndexKind.ASCENDING
    );
    validateServesTarget(
      queriesMultipleFilters,
      'a2',
      IndexKind.ASCENDING,
      'a1',
      IndexKind.ASCENDING
    );
    validateDoesNotServeTarget(
      queriesMultipleFilters,
      'a1',
      IndexKind.ASCENDING,
      'a2',
      IndexKind.ASCENDING,
      'a3',
      IndexKind.ASCENDING
    );
  });

  it('with multiple equalities and inequality', () => {
    let queriesMultipleFilters = queryWithAddedFilter(
      queryWithAddedFilter(
        queryWithAddedFilter(query('collId'), filter('equality1', '==', 'a')),
        filter('equality2', '==', 'b')
      ),
      filter('inequality', '>=', 'c')
    );
    validateServesTarget(
      queriesMultipleFilters,
      'equality1',
      IndexKind.ASCENDING,
      'equality2',
      IndexKind.ASCENDING,
      'inequality',
      IndexKind.ASCENDING
    );
    validateServesTarget(
      queriesMultipleFilters,
      'equality2',
      IndexKind.ASCENDING,
      'equality1',
      IndexKind.ASCENDING,
      'inequality',
      IndexKind.ASCENDING
    );
    validateDoesNotServeTarget(
      queriesMultipleFilters,
      'equality2',
      IndexKind.ASCENDING,
      'inequality',
      IndexKind.ASCENDING,
      'equality1',
      IndexKind.ASCENDING
    );

    queriesMultipleFilters = queryWithAddedFilter(
      queryWithAddedFilter(
        queryWithAddedFilter(query('collId'), filter('equality1', '==', 'a')),
        filter('inequality', '>=', 'c')
      ),
      filter('equality2', '==', 'b')
    );
    validateServesTarget(
      queriesMultipleFilters,
      'equality1',
      IndexKind.ASCENDING,
      'equality2',
      IndexKind.ASCENDING,
      'inequality',
      IndexKind.ASCENDING
    );
    validateServesTarget(
      queriesMultipleFilters,
      'equality2',
      IndexKind.ASCENDING,
      'equality1',
      IndexKind.ASCENDING,
      'inequality',
      IndexKind.ASCENDING
    );
    validateDoesNotServeTarget(
      queriesMultipleFilters,
      'equality1',
      IndexKind.ASCENDING,
      'inequality',
      IndexKind.ASCENDING,
      'equality2',
      IndexKind.ASCENDING
    );
  });

  it('with orderBy', () => {
    let q = queryWithAddedOrderBy(query('collId'), orderBy('a'));
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
    validateDoesNotServeTarget(q, 'a', IndexKind.DESCENDING);

    q = queryWithAddedOrderBy(query('collId'), orderBy('a', 'desc'));
    validateDoesNotServeTarget(q, 'a', IndexKind.ASCENDING);
    validateServesTarget(q, 'a', IndexKind.DESCENDING);

    q = queryWithAddedOrderBy(
      queryWithAddedOrderBy(query('collId'), orderBy('a')),
      orderBy('__name__')
    );
    validateServesTarget(
      q,
      'a',
      IndexKind.ASCENDING,
      '__name__',
      IndexKind.ASCENDING
    );
    validateDoesNotServeTarget(
      q,
      'a',
      IndexKind.ASCENDING,
      '__name__',
      IndexKind.DESCENDING
    );
  });

  it('with not equals', () => {
    let q = queryWithAddedFilter(query('collId'), filter('a', '!=', 1));
    validateServesTarget(q, 'a', IndexKind.ASCENDING);

    q = queryWithAddedOrderBy(
      queryWithAddedOrderBy(
        queryWithAddedFilter(query('collId'), filter('a', '!=', 1)),
        orderBy('a')
      ),
      orderBy('b')
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING, 'b', IndexKind.ASCENDING);
  });

  it('with mutiple filters', () => {
    const queriesMultipleFilters = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a', '==', 'a')),
      filter('b', '>', 'b')
    );
    validateServesTarget(queriesMultipleFilters, 'a', IndexKind.ASCENDING);
    validateServesTarget(
      queriesMultipleFilters,
      'a',
      IndexKind.ASCENDING,
      'b',
      IndexKind.ASCENDING
    );
  });

  it('multiple filters require matching prefix', () => {
    const queriesMultipleFilters = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a', '==', 'a')),
      filter('b', '>', 'b')
    );

    validateServesTarget(queriesMultipleFilters, 'b', IndexKind.ASCENDING);
    validateDoesNotServeTarget(
      queriesMultipleFilters,
      'c',
      IndexKind.ASCENDING,
      'a',
      IndexKind.ASCENDING
    );
  });

  it('with multiple filters and orderBy', () => {
    const queriesMultipleFilters = queryWithAddedOrderBy(
      queryWithAddedFilter(
        queryWithAddedFilter(query('collId'), filter('a1', '==', 'a')),
        filter('a2', '>', 'b')
      ),
      orderBy('a2', 'asc')
    );
    validateServesTarget(
      queriesMultipleFilters,
      'a1',
      IndexKind.ASCENDING,
      'a2',
      IndexKind.ASCENDING
    );
  });

  it('with multiple inequalities', () => {
    const q = queryWithAddedFilter(
      queryWithAddedFilter(
        queryWithAddedFilter(query('collId'), filter('a', '>=', 1)),
        filter('a', '==', 5)
      ),
      filter('a', '<=', 10)
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
  });

  it('with multiple notIns', () => {
    const q = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a', 'not-in', [1, 2, 3])),
      filter('a', '>=', 2)
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
  });

  it('with multiple orderBys', () => {
    let q = queryWithAddedOrderBy(
      queryWithAddedOrderBy(
        queryWithAddedOrderBy(query('collId'), orderBy('fff')),
        orderBy('bar', 'desc')
      ),
      orderBy('__name__')
    );
    validateServesTarget(
      q,
      'fff',
      IndexKind.ASCENDING,
      'bar',
      IndexKind.DESCENDING,
      '__name__',
      IndexKind.ASCENDING
    );
    validateDoesNotServeTarget(
      q,
      'fff',
      IndexKind.ASCENDING,
      '__name__',
      IndexKind.ASCENDING,
      'bar',
      IndexKind.DESCENDING
    );

    q = queryWithAddedOrderBy(
      queryWithAddedOrderBy(
        queryWithAddedOrderBy(query('collId'), orderBy('foo')),
        orderBy('bar')
      ),
      orderBy('__name__', 'desc')
    );
    validateServesTarget(
      q,
      'foo',
      IndexKind.ASCENDING,
      'bar',
      IndexKind.ASCENDING,
      '__name__',
      IndexKind.DESCENDING
    );
    validateDoesNotServeTarget(
      q,
      'foo',
      IndexKind.ASCENDING,
      '__name__',
      IndexKind.DESCENDING,
      'bar',
      IndexKind.ASCENDING
    );
  });

  it('with in and notIn', () => {
    const q = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a', 'not-in', [1, 2, 3])),
      filter('b', 'in', [1, 2, 3])
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
    validateServesTarget(q, 'b', IndexKind.ASCENDING);
    validateServesTarget(q, 'b', IndexKind.ASCENDING, 'a', IndexKind.ASCENDING);
    // If provided, equalities have to come first
    validateDoesNotServeTarget(
      q,
      'a',
      IndexKind.ASCENDING,
      'b',
      IndexKind.ASCENDING
    );
  });

  it('with equality and different order by', () => {
    let q = queryWithAddedOrderBy(
      queryWithAddedFilter(
        queryWithAddedFilter(query('collId'), filter('foo', '==', '')),
        filter('bar', '==', '')
      ),
      orderBy('qux')
    );
    validateServesTarget(
      q,
      'foo',
      IndexKind.ASCENDING,
      'bar',
      IndexKind.ASCENDING,
      'qux',
      IndexKind.ASCENDING
    );

    q = queryWithAddedOrderBy(
      queryWithAddedOrderBy(
        queryWithAddedFilter(
          queryWithAddedFilter(
            queryWithAddedFilter(query('collId'), filter('aaa', '==', '')),
            filter('qqq', '==', '')
          ),
          filter('ccc', '==', '')
        ),
        orderBy('fff', 'desc')
      ),
      orderBy('bbb')
    );
    validateServesTarget(
      q,
      'aaa',
      IndexKind.ASCENDING,
      'qqq',
      IndexKind.ASCENDING,
      'ccc',
      IndexKind.ASCENDING,
      'fff',
      IndexKind.DESCENDING
    );
  });

  it('with equals and not in', () => {
    const q = queryWithAddedFilter(
      queryWithAddedFilter(query('collId'), filter('a', '==', 1)),
      filter('b', 'not-in', [1, 2, 3])
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING, 'b', IndexKind.ASCENDING);
  });

  it('with in and orderBy', () => {
    const q = queryWithAddedOrderBy(
      queryWithAddedOrderBy(
        queryWithAddedFilter(query('collId'), filter('a', 'not-in', [1, 2, 3])),
        orderBy('a')
      ),
      orderBy('b')
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING, 'b', IndexKind.ASCENDING);
  });

  it('with in and orderBy on same field', () => {
    const q = queryWithAddedOrderBy(
      queryWithAddedFilter(query('collId'), filter('a', 'in', [1, 2, 3])),
      orderBy('a')
    );
    validateServesTarget(q, 'a', IndexKind.ASCENDING);
  });

  function validateServesTarget(
    query: Query,
    field: string,
    kind: IndexKind,
    ...fieldsAndKind: unknown[]
  ): void {
    const expectedIndex = fieldIndex('collId', {
      fields: convertToFieldsArray(field, kind, fieldsAndKind)
    });
    const targetIndexMatcher = new TargetIndexMatcher(queryToTarget(query));
    expect(targetIndexMatcher.servedByIndex(expectedIndex)).to.be.true;
  }

  function validateDoesNotServeTarget(
    query: Query,
    field: string,
    kind: IndexKind,
    ...fieldsAndKind: unknown[]
  ): void {
    const expectedIndex = fieldIndex('collId', {
      fields: convertToFieldsArray(field, kind, fieldsAndKind)
    });
    const targetIndexMatcher = new TargetIndexMatcher(queryToTarget(query));
    expect(targetIndexMatcher.servedByIndex(expectedIndex)).to.be.false;
  }

  function convertToFieldsArray(
    field: string,
    kind: IndexKind,
    fieldsAndKind: unknown[]
  ): Array<[field: string, kind: IndexKind]> {
    return [
      [field, kind],
      ...(fieldsAndKind.reduce((previous: unknown[][], current) => {
        if (
          previous.length === 0 ||
          previous[previous.length - 1].length === 2
        ) {
          previous.push([]);
        }
        previous[previous.length - 1].push(current);
        return previous;
      }, []) as Array<[field: string, kind: IndexKind]>)
    ];
  }
});
