# Copyright 2020 Google Inc.
#
# 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.

"""Tests for the NDB query building and filtering logic module."""

import datetime
from unittest import mock

import testing_config  # Must be imported before the module under test.
from internals import core_enums, search, search_fulltext, search_queries
from internals.core_models import FeatureEntry, MilestoneSet, Stage
from internals.review_models import Gate, Vote


class SearchFeaturesTest(testing_config.CustomTestCase):
    """Tests for searching feature entries."""

    def setUp(self):
        """Set up the test environment."""
        self.feature_1 = FeatureEntry(
            name='feature a', summary='sum', category=1, impl_status_chrome=3
        )
        self.feature_1.owner_emails = ['owner@example.com']
        self.feature_1.editor_emails = ['editor@example.com']
        self.feature_1.cc_emails = ['cc@example.com']
        self.feature_1.put()
        self.feature_1_id = self.feature_1.key.integer_id()
        search_fulltext.index_feature(self.feature_1)

        self.stage_1_ship = Stage(
            feature_id=self.feature_1_id,
            stage_type=core_enums.STAGE_BLINK_SHIPPING,
            milestones=MilestoneSet(desktop_first=99, android_first=100),
        )
        self.stage_1_ship.put()

        self.feature_2 = FeatureEntry(
            name='feature b',
            summary='summary of editor stuff',
            owner_emails=['owner@example.com'],
            category=1,
            impl_status_chrome=3,
            accurate_as_of=datetime.datetime(2023, 6, 1),
        )
        self.feature_2.put()
        search_fulltext.index_feature(self.feature_1)
        self.feature_2_id = self.feature_2.key.integer_id()
        self.stage_2_ot = Stage(
            feature_id=self.feature_2_id,
            stage_type=core_enums.STAGE_BLINK_ORIGIN_TRIAL,
            milestones=MilestoneSet(
                desktop_first=89, desktop_last=95, webview_first=100
            ),  # noqa: E501
        )
        self.stage_2_ot.put()

        self.feature_3 = FeatureEntry(
            name='feature c',
            summary='sum',
            owner_emails=['random@example.com'],
            category=1,
            impl_status_chrome=4,
            accurate_as_of=datetime.datetime(2024, 6, 1),
        )
        self.feature_3.put()
        self.feature_3_id = self.feature_3.key.integer_id()

        self.gate_1 = Gate(
            feature_id=self.feature_1_id,
            stage_id=1,
            gate_type=core_enums.GATE_API_PROTOTYPE,
            state=Vote.APPROVED,
            requested_on=datetime.datetime(2022, 7, 1),
        )
        self.gate_1.put()
        self.gate_1_id = self.gate_1.key.integer_id()

        self.vote_1_1 = Vote(
            feature_id=self.feature_1_id,
            gate_type=core_enums.GATE_API_PROTOTYPE,
            gate_id=self.gate_1_id,
            state=Vote.REVIEW_REQUESTED,
            set_on=datetime.datetime(2022, 7, 1),
            set_by='feature_owner@example.com',
        )
        self.vote_1_1.put()

        self.vote_1_2 = Vote(
            feature_id=self.feature_1_id,
            gate_type=core_enums.GATE_API_PROTOTYPE,
            gate_id=self.gate_1_id,
            state=Vote.APPROVED,
            set_on=datetime.datetime(2022, 7, 2),
            set_by='reviewer@example.com',
        )
        self.vote_1_2.put()

        self.gate_2 = Gate(
            feature_id=self.feature_2_id,
            stage_id=1,
            gate_type=core_enums.GATE_API_SHIP,
            state=Vote.REVIEW_REQUESTED,
            requested_on=datetime.datetime(2022, 8, 1),
        )
        self.gate_2.put()
        self.gate_2_id = self.gate_2.key.integer_id()

        self.vote_2_1 = Vote(
            feature_id=self.feature_2_id,
            gate_type=core_enums.GATE_API_SHIP,
            gate_id=self.gate_2_id,
            state=Vote.REVIEW_REQUESTED,
            set_on=datetime.datetime(2022, 8, 1),
            set_by='feature_owner@example.com',
        )
        self.vote_2_1.put()

        self.vote_2_2 = Vote(
            feature_id=self.feature_2_id,
            gate_type=core_enums.GATE_API_SHIP,
            gate_id=self.gate_2_id,
            state=Vote.APPROVED,
            set_on=datetime.datetime(2022, 8, 2),
            set_by='reviewer@example.com',
        )
        self.vote_2_2.put()

    def tearDown(self):
        """Clean up the test environment."""
        for kind in [
            FeatureEntry,
            search_fulltext.FeatureWords,
            Stage,
            Gate,
            Vote,
        ]:
            for entry in kind.query():
                entry.key.delete()

    def test_single_field_query_async__normal(self):
        """We get a promise to run the DB query, which produces results."""
        actual_promise = search_queries.single_field_query_async(
            'owner', '=', ['owner@example.com']
        )
        actual = actual_promise.get_result()
        self.assertCountEqual(
            [self.feature_1_id, self.feature_2_id],
            [key.integer_id() for key in actual],
        )

        actual_promise = search_queries.single_field_query_async(
            'unlisted', '=', [True]
        )
        actual = actual_promise.get_result()
        self.assertCountEqual([], actual)

        actual_promise = search_queries.single_field_query_async(
            'deleted', '=', [True]
        )
        actual = actual_promise.get_result()
        self.assertCountEqual([], actual)

    def test_single_field_query_async__multiple_vals(self):
        """We get a promise to run the DB query with multiple values."""
        actual_promise = search_queries.single_field_query_async(
            'owner', '=', ['owner@example.com', 'random@example.com']
        )
        actual = actual_promise.get_result()
        self.assertCountEqual(
            [self.feature_1_id, self.feature_2_id, self.feature_3_id],
            [key.integer_id() for key in actual],
        )

    def test_single_field_query_async__inequality_nulls_first(self):
        """accurate_as_of treats None as before any comparison value."""
        actual_promise = search_queries.single_field_query_async(
            'accurate_as_of', '<', [datetime.datetime(2024, 1, 1)]
        )
        actual = actual_promise.get_result()
        self.assertCountEqual(
            [self.feature_1_id, self.feature_2_id],
            [key.integer_id() for key in actual],  # noqa: E501
        )

        actual_promise = search_queries.single_field_query_async(
            'accurate_as_of', '>', [datetime.datetime(2024, 1, 1)]
        )
        actual = actual_promise.get_result()
        self.assertCountEqual(
            [self.feature_3_id], [key.integer_id() for key in actual]
        )  # noqa: E501

    def test_single_field_query_async__any_start_milestone(self):
        """Test single field query async  any start milestone."""
        actual = search_queries.single_field_query_async(
            'any_start_milestone', '=', [100]
        ).get_result()
        self.assertEqual(
            set([self.feature_1_id, self.feature_2_id]),
            set(proj.feature_id for proj in actual),
            'Finds across multiple milestones.',
        )

        actual = search_queries.single_field_query_async(
            'any_start_milestone', '=', [95]
        ).get_result()
        self.assertEqual(
            set(),
            set(proj.feature_id for proj in actual),
            'Does not find "last" milestones.',  # noqa: E501
        )

        actual = search_queries.single_field_query_async(
            'any_start_milestone', '=', [search_queries.Interval(97, 99)]
        ).get_result()
        self.assertCountEqual(
            set([self.feature_1_id]),
            set(proj.feature_id for proj in actual),
            'Intervals are constrained to a single milestone.',
        )

    def check_wrong_type(self, field_name, bad_values):
        """Check wrong type."""
        with self.assertRaises(ValueError) as cm:
            search_queries.single_field_query_async(field_name, '=', bad_values)
        self.assertEqual(
            cm.exception.args[0], 'Query value does not match field type'
        )

    def test_single_field_query_async__wrong_types(self):
        """We reject requests with values that parse to the wrong type."""
        # Feature entry fields
        self.check_wrong_type('owner', [True])
        self.check_wrong_type('owner', [123])
        self.check_wrong_type('deleted', ['not a boolean'])
        self.check_wrong_type('shipping_year', ['not an integer'])
        self.check_wrong_type('star_count', ['not an integer'])
        self.check_wrong_type('created.when', ['not a date'])
        self.check_wrong_type('owner', ['ok@example.com', True])

        # Stage fields
        self.check_wrong_type('browsers.chrome.android', ['not an integer'])
        self.check_wrong_type('finch_url', [123])
        self.check_wrong_type('finch_url', [True])

    def test_single_field_query_async__normal_stage_field(self):
        """We can find a FeatureEntry based on values in an associated Stage."""
        actual_promise = search_queries.single_field_query_async(
            'browsers.chrome.desktop', '=', [99]
        )
        actual = actual_promise.get_result()
        self.assertCountEqual(
            [self.feature_1_id],
            [projection.feature_id for projection in actual],
        )

    def test_single_field_query_async__other_stage_field(self):
        """We only consider the appropriate Stage."""
        actual_promise = search_queries.single_field_query_async(
            'browsers.chrome.ot.desktop.start', '=', [99]
        )
        actual = actual_promise.get_result()
        self.assertCountEqual([], actual)

    def test_single_field_query_async__zero_results(self):
        """When there are no matching results, we get back a promise for []."""
        actual_promise = search_queries.single_field_query_async(
            'owner', '=', ['nope']
        )
        actual = actual_promise.get_result()
        self.assertCountEqual([], actual)

    def test_single_field_query_async__fulltext_in_field(self):
        """We can search for words within a field."""
        actual = search_queries.single_field_query_async(
            'editor', ':', ['editor']
        )
        self.assertCountEqual([self.feature_1_id], actual)

        actual = search_queries.single_field_query_async(
            'editor', ':', ['wrongword']
        )
        self.assertCountEqual([], actual)

        actual = search_queries.single_field_query_async(
            'owner', ':', ['editor']
        )
        self.assertCountEqual([], actual)

    @mock.patch('logging.warning')
    def test_single_field_query_async__bad_field(self, mock_warn):
        """An unknown field imediately gives zero results."""
        actual = search_queries.single_field_query_async('zodiac', '=', ['leo'])
        self.assertCountEqual([], actual)

    def test_handle_me_query_async__owner_anon(self):
        """We can return a list of features owned by the user."""
        testing_config.sign_in('visitor@example.com', 111)
        future = search_queries.handle_me_query_async('owner')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual(actual, [])

    def test_handle_me_query__owner_some(self):
        """We can return a list of features owned by the user."""
        testing_config.sign_in('owner@example.com', 111)
        future = search_queries.handle_me_query_async('owner')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([self.feature_1_id, self.feature_2_id], actual)

    def test_handle_me_query__editor_none(self):
        """We can return a list of features the user can edit."""
        testing_config.sign_in('visitor@example.com', 111)
        future = search_queries.handle_me_query_async('editor')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([], actual)

    def test_handle_me_query__editor_some(self):
        """We can return a list of features the user can edit."""
        testing_config.sign_in('editor@example.com', 111)
        future = search_queries.handle_me_query_async('editor')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([self.feature_1_id], actual)

    def test_handle_me_query__cc_none(self):
        """We can return a list of features the user is CC'd on."""
        testing_config.sign_in('visitor@example.com', 111)
        future = search_queries.handle_me_query_async('cc')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual(actual, [])

    def test_handle_me_query__cc_some(self):
        """We can return a list of features the user is CC'd on."""
        testing_config.sign_in('cc@example.com', 111)
        future = search_queries.handle_me_query_async('cc')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([self.feature_1_id], actual)

    def test_handle_can_edit_me_query_async__anon(self):
        """Anon cannot edit any features."""
        testing_config.sign_out()
        future = search_queries.handle_can_edit_me_query_async()
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([], actual)

    def test_handle_can_edit_me_query_async__visitor(self):
        """Visitor cannot edit any features."""
        testing_config.sign_in('visitor@example.com', 111)
        future = search_queries.handle_can_edit_me_query_async()
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([], actual)

    def test_handle_can_edit_me_query_async__owner(self):
        """A feature owner can edit those features."""
        testing_config.sign_in('owner@example.com', 111)
        future = search_queries.handle_can_edit_me_query_async()
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([self.feature_1_id, self.feature_2_id], actual)

    def test_handle_can_edit_me_query_async__editor(self):
        """A feature editor can edit those features."""
        testing_config.sign_in('editor@example.com', 111)
        future = search_queries.handle_can_edit_me_query_async()
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([self.feature_1_id], actual)

    def test_total_order_query_async__field_asc(self):
        """We can get keys used to sort features in ascending order."""
        future = search_queries.total_order_query_async('name')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual(
            [self.feature_1_id, self.feature_2_id, self.feature_3_id], actual
        )

    def test_total_order_query_async__field_desc(self):
        """We can get keys used to sort features in descending order."""
        future = search_queries.total_order_query_async('-name')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual(
            [self.feature_3_id, self.feature_2_id, self.feature_1_id], actual
        )

    def test_total_order_query_async__requested_on(self):
        """We can get feature IDs sorted by gate review requests."""
        future = search_queries.total_order_query_async('gate.requested_on')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([self.feature_2_id], actual)

    def test_total_order_query_async__reviewed_on(self):
        """We can get feature IDs sorted by gate resolution times."""
        future = search_queries.total_order_query_async('gate.reviewed_on')
        actual = search._resolve_promise_to_id_list(future)
        self.assertEqual([self.feature_1_id, self.feature_2_id], actual)

    def test_stage_fields_have_join_conditions(self):
        """Every STAGE_QUERIABLE_FIELDS has a STAGE_TYPES_BY_QUERY_FIELD entry."""
        self.assertCountEqual(
            search_queries.STAGE_QUERIABLE_FIELDS.keys(),
            search_queries.STAGE_TYPES_BY_QUERY_FIELD.keys(),
        )

    def test_negate_operator(self):
        """We can get correct negated operators"""  # noqa: D415
        actual = search_queries.negate_operator('=')
        self.assertEqual('!=', actual)

        actual = search_queries.negate_operator('!=')
        self.assertEqual('=', actual)

        actual = search_queries.negate_operator('<')
        self.assertEqual('>=', actual)

        actual = search_queries.negate_operator('<=')
        self.assertEqual('>', actual)

        actual = search_queries.negate_operator('>')
        self.assertEqual('<=', actual)

        actual = search_queries.negate_operator('>=')
        self.assertEqual('<', actual)
