/* eslint-disable @typescript-eslint/no-explicit-any,unicorn/no-null */
/*
 * Copyright (C) 2020 StApps
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
import {
  SCConfigFile,
  SCSearchBooleanFilter,
  SCSearchDateRangeFilter,
  SCSearchFilter,
  SCSearchNumericRangeFilter,
  SCSearchQuery,
  SCSearchSort,
  SCThingType,
} from '@openstapps/core';
import {expect} from 'chai';
import {buildFilter} from '../../../src/storage/elasticsearch/query/filter.js';
import {buildBooleanFilter} from '../../../src/storage/elasticsearch/query/filters/boolean.js';
import {buildQuery} from '../../../src/storage/elasticsearch/query/query.js';
import {buildSort} from '../../../src/storage/elasticsearch/query/sort.js';
import {ElasticsearchConfig} from '../../../src/storage/elasticsearch/types/elasticsearch-config.js';
import {QueryDslSpecificQueryContainer} from '../../../src/storage/elasticsearch/types/util.js';
import {QueryDslRangeQuery, SortCombinations} from '@elastic/elasticsearch/lib/api/types.js';
import {backendConfig} from '../../../src/config.js';

describe('Query', function () {
  describe('buildBooleanFilter', function () {
    const booleanFilter: SCSearchBooleanFilter = {
      arguments: {
        operation: 'and',
        filters: [
          {
            type: 'value',
            arguments: {
              field: 'type',
              value: SCThingType.Catalog,
            },
          },
          {
            type: 'value',
            arguments: {
              field: 'type',
              value: SCThingType.Building,
            },
          },
        ],
      },
      type: 'boolean',
    };
    const booleanFilters: {[key: string]: SCSearchBooleanFilter} = {
      and: booleanFilter,
      or: {...booleanFilter, arguments: {...booleanFilter.arguments, operation: 'or'}},
      not: {...booleanFilter, arguments: {...booleanFilter.arguments, operation: 'not'}},
    };
    const expectedEsFilters: Array<QueryDslSpecificQueryContainer<'term'>> = [
      {
        term: {
          'type.raw': 'catalog',
        },
      },
      {
        term: {
          'type.raw': 'building',
        },
      },
    ];

    it('should create appropriate elasticsearch "and" filter argument', function () {
      const {must} = buildBooleanFilter(booleanFilters.and).bool;

      expect(must).to.be.eql(expectedEsFilters);
    });

    it('should create appropriate elasticsearch "or" filter argument', function () {
      const {should, minimum_should_match} = buildBooleanFilter(booleanFilters.or).bool;

      expect(should).to.be.eql(expectedEsFilters);
      expect(minimum_should_match).to.be.equal(1);
    });

    it('should create appropriate elasticsearch "not" filter argument', function () {
      const {must_not} = buildBooleanFilter(booleanFilters.not).bool;

      expect(must_not).to.be.eql(expectedEsFilters);
    });
  });

  describe('buildQuery', function () {
    const parameters: SCSearchQuery = {
      query: 'mathematics',
      from: 30,
      size: 5,
      sort: [
        {
          type: 'ducet',
          order: 'desc',
          arguments: {
            field: 'name',
          },
        },
        {
          type: 'ducet',
          order: 'desc',
          arguments: {
            field: 'categories',
          },
        },
      ],
      filter: {
        type: 'value',
        arguments: {
          field: 'type',
          value: SCThingType.AcademicEvent,
        },
      },
    };
    let esConfig: ElasticsearchConfig = {
      name: 'elasticsearch',
      version: '123',
      query: {
        minMatch: '75%',
        queryType: 'dis_max',
        matchBoosting: 1.3,
        fuzziness: 'AUTO',
        cutoffFrequency: 0,
        tieBreaker: 0,
      },
    };
    const query = {
      minMatch: '75%',
      queryType: 'dis_max',
      matchBoosting: 1.3,
      fuzziness: 'AUTO',
      cutoffFrequency: 0,
      tieBreaker: 0,
    };
    const config: SCConfigFile = {
      ...backendConfig,
    };
    beforeEach(function () {
      esConfig = {
        name: 'elasticsearch',
        version: '123',
      };
    });

    // TODO: check parts of received elasticsearch query for each test case

    it('should build query that includes sorting when query is undefined', function () {
      expect(buildQuery(parameters, config, esConfig)).to.be.an('object');
    });

    it('should build query that includes sorting when query type is query_string', function () {
      esConfig.query = {...query, queryType: 'query_string'};

      expect(buildQuery(parameters, config, esConfig)).to.be.an('object');
    });

    it('should build query that includes sorting when query type is dis_max', function () {
      esConfig.query = {...query, queryType: 'dis_max'};

      expect(buildQuery(parameters, config, esConfig)).to.be.an('object');
    });

    it('should build query that includes sorting when query type is dis_max', function () {
      esConfig.query = {...query, queryType: 'dis_max'};

      expect(buildQuery(parameters, config, esConfig)).to.be.an('object');
    });

    it('should reject (throw an error) if provided query type is not supported', function () {
      // @ts-expect-error not assignable
      esConfig.query = {...query, queryType: 'invalid_query_type'};

      expect(() => buildQuery(parameters, config, esConfig)).to.throw('query type');
    });

    it('should accept other search contexts', function () {
      expect(buildQuery({context: 'place', ...parameters}, config, esConfig)).to.be.an('object');
    });
  });

  describe('buildFilter', function () {
    const searchFilters: {[key: string]: SCSearchFilter} = {
      value: {
        type: 'value',
        arguments: {
          field: 'type',
          value: SCThingType.Dish,
        },
      },
      distance: {
        type: 'distance',
        arguments: {
          distance: 1000,
          field: 'geo',
          position: [50.123, 8.123],
        },
      },
      geoPoint: {
        type: 'geo',
        arguments: {
          field: 'geo',
          shape: {
            type: 'envelope',
            coordinates: [
              [50.123, 8.123],
              [50.123, 8.123],
            ],
          },
        },
      },
      geoShape: {
        type: 'geo',
        arguments: {
          field: 'geo',
          spatialRelation: 'contains',
          shape: {
            type: 'envelope',
            coordinates: [
              [50.123, 8.123],
              [50.123, 8.123],
            ],
          },
        },
      },
      boolean: {
        type: 'boolean',
        arguments: {
          operation: 'and',
          filters: [
            {
              type: 'value',
              arguments: {
                field: 'type',
                value: SCThingType.Dish,
              },
            },
            {
              type: 'availability',
              arguments: {
                field: 'offers.availabilityRange',
              },
            },
          ],
        },
      },
    };

    it('should build value filter', function () {
      const filter = buildFilter(searchFilters.value);
      const expectedFilter: QueryDslSpecificQueryContainer<'term'> = {
        term: {
          'type.raw': SCThingType.Dish,
        },
      };

      expect(filter).to.be.eql(expectedFilter);
    });

    it('should build numeric range filters', function () {
      for (const upperMode of ['inclusive', 'exclusive', null]) {
        for (const lowerMode of ['inclusive', 'exclusive', null]) {
          const expectedFilter: QueryDslSpecificQueryContainer<'range'> = {
            range: {
              price: {
                relation: undefined,
              },
            },
          };

          const rawFilter: SCSearchNumericRangeFilter = {
            type: 'numeric range',
            arguments: {
              bounds: {},
              field: 'price',
            },
          };

          // eslint-disable-next-line unicorn/consistent-function-scoping
          const setBound = (location: 'upperBound' | 'lowerBound', bound: string | null) => {
            let out: number | null = null;
            if (bound != undefined) {
              out = Math.random();
              rawFilter.arguments.bounds[location] = {
                mode: bound as 'inclusive' | 'exclusive',
                limit: out,
              };
              expectedFilter.range.price![
                `${location === 'lowerBound' ? 'g' : 'l'}${bound === 'inclusive' ? 'te' : 't'}`
              ] = out;
            }
          };
          setBound('upperBound', upperMode);
          setBound('lowerBound', lowerMode);

          const filter = buildFilter(rawFilter) as QueryDslSpecificQueryContainer<'term'>;
          expect(filter).to.deep.equal(expectedFilter);
          for (const bound of ['g', 'l']) {
            // @ts-expect-error implicit any
            const inclusiveExists = typeof filter.range.price[`${bound}t`] !== 'undefined';
            // @ts-expect-error implicit any
            const exclusiveExists = typeof filter.range.price[`${bound}te`] !== 'undefined';

            // only one should exist at the same time
            expect(inclusiveExists && exclusiveExists).to.be.false;
          }
        }
      }
    });

    it('should build date range filters', function () {
      for (const upperMode of ['inclusive', 'exclusive', null]) {
        for (const lowerMode of ['inclusive', 'exclusive', null]) {
          const expectedFilter: QueryDslSpecificQueryContainer<'range'> = {
            range: {
              price: {
                format: 'thisIsADummyFormat',
                time_zone: 'thisIsADummyTimeZone',
                relation: 'testRelation' as any,
              },
            },
          };

          const rawFilter: SCSearchDateRangeFilter = {
            type: 'date range',
            arguments: {
              bounds: {},
              field: 'price',
              relation: 'testRelation' as any,
              format: 'thisIsADummyFormat',
              timeZone: 'thisIsADummyTimeZone',
            },
          };

          const setBound = (location: 'upperBound' | 'lowerBound', bound: string | null) => {
            let out: string | null = null;
            if (bound != undefined) {
              out = `${location} ${bound} ${upperMode} ${lowerMode}`;
              rawFilter.arguments.bounds[location] = {
                mode: bound as 'inclusive' | 'exclusive',
                limit: out,
              };
              expectedFilter.range.price![
                `${location === 'lowerBound' ? 'g' : 'l'}${bound === 'inclusive' ? 'te' : 't'}`
              ] = out;
            }
          };
          setBound('upperBound', upperMode);
          setBound('lowerBound', lowerMode);

          const filter = buildFilter(rawFilter) as QueryDslSpecificQueryContainer<'range'>;
          expect(filter).to.deep.equal(expectedFilter);
          for (const bound of ['g', 'l']) {
            const inclusiveExists =
              typeof filter.range.price?.[`${bound}t` as keyof QueryDslRangeQuery] !== 'undefined';
            const exclusiveExists =
              typeof filter.range.price?.[`${bound}te` as keyof QueryDslRangeQuery] !== 'undefined';

            // only one should exist at the same time
            expect(inclusiveExists && exclusiveExists).to.be.false;
          }
        }
      }
    });

    it('should build availability filters', function () {
      it('should copy scope', function () {
        for (const scope of ['a', 'b']) {
          const filter = buildFilter({
            type: 'availability',
            arguments: {
              time: 'test',
              scope: scope as any,
              field: 'offers.availabilityRange',
            },
          });

          const expectedFilter: QueryDslSpecificQueryContainer<'range'> = {
            range: {
              'offers.availabilityRange': {
                gte: `test||/${scope}`,
                lt: `test||+1${scope}/${scope}`,
              },
            },
          };
          expect(filter).to.be.eql(expectedFilter);
        }
      });

      it('should default to second scope', function () {
        const filter = buildFilter({
          type: 'availability',
          arguments: {
            time: 'test',
            field: 'offers.availabilityRange',
          },
        });

        const expectedFilter: QueryDslSpecificQueryContainer<'range'> = {
          range: {
            'offers.availabilityRange': {
              gte: 'test||/s',
              lt: 'test||+1s/s',
            },
          },
        };
        expect(filter).to.be.eql(expectedFilter);
      });

      it('should add || to dates', function () {
        const filter = buildFilter({
          type: 'availability',
          arguments: {
            time: 'test',
            scope: 'd',
            field: 'offers.availabilityRange',
          },
        });

        const expectedFilter: QueryDslSpecificQueryContainer<'range'> = {
          range: {
            'offers.availabilityRange': {
              gte: `test||/d`,
              lt: `test||+1d/d`,
            },
          },
        };
        expect(filter).to.be.eql(expectedFilter);
      });

      it('should default to now and not add ||', function () {
        const filter = buildFilter({
          type: 'availability',
          arguments: {
            scope: 'd',
            field: 'offers.availabilityRange',
          },
        });

        const expectedFilter: QueryDslSpecificQueryContainer<'range'> = {
          range: {
            'offers.availabilityRange': {
              gte: `now/d`,
              lt: `now+1d/d`,
            },
          },
        };
        expect(filter).to.be.eql(expectedFilter);
      });
    });

    it('should build distance filter', function () {
      const filter = buildFilter(searchFilters.distance);
      const expectedFilter: QueryDslSpecificQueryContainer<'geo_distance'> = {
        geo_distance: {
          'distance': '1000m',
          'geo.point.coordinates': {
            lat: 8.123,
            lon: 50.123,
          },
        },
      };

      expect(filter).to.be.eql(expectedFilter);
    });

    it('should build geo filter for shapes and points', function () {
      const filter = buildFilter(searchFilters.geoPoint);
      const expectedFilter = {
        bool: {
          should: [
            {
              geo_shape: {
                'geo.polygon': {
                  relation: undefined,
                  shape: {
                    coordinates: [
                      [50.123, 8.123],
                      [50.123, 8.123],
                    ],
                    type: 'envelope',
                  },
                },
                'ignore_unmapped': true,
              },
            },
            {
              geo_shape: {
                'geo.point': {
                  relation: undefined,
                  shape: {
                    coordinates: [
                      [50.123, 8.123],
                      [50.123, 8.123],
                    ],
                    type: 'envelope',
                  },
                },
                'ignore_unmapped': true,
              },
            },
          ],
        },
      };

      expect(filter).to.be.eql(expectedFilter);
    });

    it('should build geo filter for shapes only', function () {
      const filter = buildFilter(searchFilters.geoShape);
      const expectedFilter = {
        bool: {
          should: [
            {
              geo_shape: {
                'geo.polygon': {
                  relation: 'contains',
                  shape: {
                    coordinates: [
                      [50.123, 8.123],
                      [50.123, 8.123],
                    ],
                    type: 'envelope',
                  },
                },
                'ignore_unmapped': true,
              },
            },
            {
              geo_shape: {
                'geo.point': {
                  relation: 'contains',
                  shape: {
                    coordinates: [
                      [50.123, 8.123],
                      [50.123, 8.123],
                    ],
                    type: 'envelope',
                  },
                },
                'ignore_unmapped': true,
              },
            },
          ],
        },
      };

      expect(filter).to.be.eql(expectedFilter);
    });

    it('should build boolean filter', function () {
      const filter = buildFilter(searchFilters.boolean);
      const expectedFilter: QueryDslSpecificQueryContainer<'bool'> = {
        bool: {
          minimum_should_match: 0,
          must: [
            {
              term: {
                'type.raw': 'dish',
              },
            },
            {
              range: {
                'offers.availabilityRange': {
                  gte: 'now/s',
                  lt: 'now+1s/s',
                  relation: 'intersects',
                },
              },
            },
          ],
          must_not: [],
          should: [],
        },
      };

      expect(filter).to.be.eql(expectedFilter);
    });
  });

  describe('buildSort', function () {
    const searchSCSearchSort: Array<SCSearchSort> = [
      {
        type: 'ducet',
        order: 'desc',
        arguments: {
          field: 'name',
        },
      },
      {
        type: 'generic',
        order: 'desc',
        arguments: {
          field: 'name',
        },
      },
      {
        type: 'distance',
        order: 'desc',
        arguments: {
          field: 'geo',
          position: [8.123, 50.123],
        },
      },
      {
        type: 'price',
        order: 'asc',
        arguments: {
          universityRole: 'student',
          field: 'offers.prices',
        },
      },
    ];
    let sorts: SortCombinations[] = [];
    const expectedSorts: {[key: string]: SortCombinations} = {
      ducet: {
        'name.sort': 'desc',
      },
      generic: {
        name: 'desc',
      },
      distance: {
        _geo_distance: {
          'mode': 'avg',
          'order': 'desc',
          'unit': 'm',
          'geo.point.coordinates': {
            lat: 50.123,
            lon: 8.123,
          },
        },
      },
      price: {
        _script: {
          order: 'asc',
          script: '\n  // foo price sort script',
          type: 'number',
        },
      },
    };
    before(function () {
      sorts = buildSort(searchSCSearchSort) as SortCombinations[];
    });

    it('should build ducet sort', function () {
      expect(sorts[0]).to.be.eql(expectedSorts.ducet);
    });

    it('should build generic sort', function () {
      expect(sorts[1]).to.be.eql(expectedSorts.generic);
    });

    it('should build distance sort', function () {
      expect(sorts[2]).to.be.eql(expectedSorts.distance);
    });

    it('should build price sort', function () {
      const priceSortNoScript = {
        ...(sorts[3] as any),
        _script: {
          ...(sorts[3] as any)._script,
          script: (expectedSorts.price as any)._script.script,
        },
      };
      expect(priceSortNoScript).to.be.eql(expectedSorts.price);
    });
  });
});
