# -*- coding: utf-8 -*-
# Copyright 2018, CS GROUP - France, https://www.csgroup.eu/
#
# This file is part of EODAG project
#     https://www.github.com/CS-SI/EODAG
#
# 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 ast
import datetime as dt
import json
import os
import re
import ssl
import unittest
from copy import deepcopy as copy_deepcopy
from importlib import import_module
from pathlib import Path
from typing import Annotated, Literal, Union, get_args, get_origin
from unittest import mock
from unittest.mock import call

import boto3
import botocore
import pytest
import requests
import responses
from botocore.stub import Stubber
from jsonpath_ng import JSONPath, parse
from pydantic import Field
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from requests import RequestException
from shapely.geometry.base import BaseGeometry

from eodag.api.product import AssetsDict
from eodag.api.product.metadata_mapping import get_queryable_from_provider
from eodag.api.provider import Provider, ProvidersDict
from eodag.api.search_result import RawSearchResult
from eodag.plugins.search.cop_ghsl import (
    _convert_bbox_to_lonlat_EPSG3035,
    _convert_bbox_to_lonlat_mollweide,
    _get_available_values_from_constraints,
    _replace_datetimes,
)
from eodag.utils import deepcopy
from eodag.utils.exceptions import (
    PluginImplementationError,
    QuotaExceededError,
    UnsupportedCollection,
    ValidationError,
)
from tests.context import (
    DEFAULT_SEARCH_TIMEOUT,
    HTTP_REQ_TIMEOUT,
    NOT_AVAILABLE,
    TEST_RESOURCES_PATH,
    USER_AGENT,
    AuthenticationError,
    EOProduct,
    MisconfiguredError,
    NotAvailableError,
    PluginManager,
    PreparedSearch,
    QueryablesDict,
    QueryStringSearch,
    RequestError,
    TimeOutError,
    cached_parse,
    cached_yaml_load_all,
    ecmwf_temporal_to_eodag,
    get_geometry_from_various,
    load_default_config,
)


class BaseSearchPluginTest(unittest.TestCase):
    def setUp(self):
        super(BaseSearchPluginTest, self).setUp()
        providers = ProvidersDict.from_configs(load_default_config())
        self.plugins_manager = PluginManager(providers)
        self.collection = "S2_MSI_L1C"
        geom = [137.772897, 13.134202, 153.749135, 23.885986]
        geometry = get_geometry_from_various([], geometry=geom)
        self.search_criteria_s2_msi_l1c = {
            "collection": self.collection,
            "start_datetime": "2020-08-08",
            "end_datetime": "2020-08-16",
            "geometry": geometry,
        }
        self.provider_resp_dir = Path(TEST_RESOURCES_PATH) / "provider_responses"

    def get_search_plugin(self, collection=None, provider=None):
        return next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider=provider
            )
        )

    def get_auth_plugin(self, search_plugin):
        return self.plugins_manager.get_auth_plugin(search_plugin)

    def test_get_assets_from_mapping(self):
        search_plugin = self.get_search_plugin(provider="geodes")
        search_plugin.config.assets_mapping = {
            "one": {"href": "$.properties.href", "roles": ["a_role"], "title": "One"},
            "two": {
                "href": "https://a.static_url.com",
                "roles": ["a_role"],
                "title": "Two",
            },
        }
        provider_item = {"id": "ID123456", "properties": {"href": "a.product.com/ONE"}}
        asset_mappings = search_plugin.get_assets_from_mapping(provider_item)
        self.assertEqual(2, len(asset_mappings))
        self.assertEqual("a.product.com/ONE", asset_mappings["one"]["href"])
        self.assertEqual("One", asset_mappings["one"]["title"])
        self.assertListEqual(["a_role"], asset_mappings["one"]["roles"])
        self.assertEqual("https://a.static_url.com", asset_mappings["two"]["href"])
        self.assertEqual("Two", asset_mappings["two"]["title"])
        self.assertListEqual(["a_role"], asset_mappings["two"]["roles"])


class TestSearchPluginQueryStringSearchXml(BaseSearchPluginTest):
    def setUp(self):
        super().setUp()

        provider = "mundi"

        # manually add conf as this provider is not supported any more
        mundi_config_dict = cached_yaml_load_all(
            Path(TEST_RESOURCES_PATH) / "mundi_conf.yml"
        )[0]
        # Provider YAML now uses provider name as top-level key
        provider_name, mundi_config = next(iter(mundi_config_dict.items()))
        if "name" not in mundi_config:
            mundi_config["name"] = provider_name
        self.plugins_manager.providers[provider] = Provider(mundi_config)
        self.plugins_manager.rebuild()

        # One of the providers that has a QueryStringSearch Search plugin and result_type=xml
        self.mundi_search_plugin = self.get_search_plugin(self.collection, provider)
        self.mundi_auth_plugin = self.get_auth_plugin(self.mundi_search_plugin)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_xml_count_and_search_mundi(
        self, mock__request
    ):
        """A query with a QueryStringSearch (mundi here) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        with open(self.provider_resp_dir / "mundi_search.xml", "rb") as f:
            mundi_resp_search = f.read()
        mock__request.return_value = mock.Mock()
        mock__request.return_value.content = mundi_resp_search

        products = self.mundi_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth_plugin=self.mundi_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        mundi_url_search = (
            "https://Sentinel2.browse.catalog.mundiwebservices.com/opensearch?timeStart=2020-08-08T00:00:00.000Z&"
            "timeEnd=2020-08-16T00:00:00.000Z&geometry="
            "POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, 137.7729 13.1342))&"
            "productType=IMAGE&processingLevel=L1C&format=atom&relation=intersects&maxRecords=2&startIndex=1"
        )
        mundi_products_count = 47
        number_of_products = 2

        mock__request.assert_called_once()
        self.assertEqual(mock__request.call_args_list[-1][0][1].url, mundi_url_search)

        self.assertEqual(products.number_matched, mundi_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.count_hits", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_xml_no_count_and_search_mundi(
        self, mock__request, mock_count_hits
    ):
        """A query with a QueryStringSearch (here mundi) without a count"""
        with open(self.provider_resp_dir / "mundi_search.xml", "rb") as f:
            mundi_resp_search = f.read()
        mock__request.return_value = mock.Mock()
        mock__request.return_value.content = mundi_resp_search

        products = self.mundi_search_plugin.query(
            prep=PreparedSearch(
                count=False,
                page=1,
                limit=2,
                auth_plugin=self.mundi_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        mundi_url_search = (
            "https://Sentinel2.browse.catalog.mundiwebservices.com/opensearch?timeStart=2020-08-08T00:00:00.000Z&"
            "timeEnd=2020-08-16T00:00:00.000Z&geometry="
            "POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, 137.7729 13.1342))&"
            "productType=IMAGE&processingLevel=L1C&format=atom&relation=intersects&maxRecords=2&startIndex=1"
        )
        number_of_products = 2

        mock_count_hits.assert_not_called()
        mock__request.assert_called_once()
        self.assertEqual(mock__request.call_args_list[-1][0][1].url, mundi_url_search)

        self.assertIsNone(products.number_matched)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.count_hits", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_xml_distinct_collection_mtd_mapping(
        self, mock__request, mock_count_hits
    ):
        """The metadata mapping for XML QueryStringSearch should not mix specific collections metadata-mapping"""
        with open(self.provider_resp_dir / "mundi_search.xml", "rb") as f:
            mundi_resp_search = f.read()
        mock__request.return_value = mock.Mock()
        mock__request.return_value.content = mundi_resp_search

        search_plugin = self.get_search_plugin(self.collection, "mundi")

        # update metadata_mapping only for S1_SAR_GRD
        search_plugin.config.products["S1_SAR_GRD"]["metadata_mapping"]["bar"] = (
            None,
            "dc:creator/text()",
        )
        products = search_plugin.query(
            collection="S1_SAR_GRD",
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "dhus")

        # search with another collection
        self.assertNotIn(
            "bar", search_plugin.config.products["S1_SAR_SLC"]["metadata_mapping"]
        )
        products = search_plugin.query(
            collection="S1_SAR_SLC",
        )
        self.assertNotIn("bar", products.data[0].properties)


class TestSearchPluginQueryStringSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginQueryStringSearch, self).setUp()
        # One of the providers that has a QueryStringSearch Search plugin
        provider = "sara"
        self.sara_search_plugin = self.get_search_plugin(self.collection, provider)
        self.sara_auth_plugin = self.get_auth_plugin(self.sara_search_plugin)

    def test_plugins_search_querystringsearch_collect_search_urls_without_numeric_paging(
        self,
    ):
        """collect_search_urls must still build a URL when numeric paging is disabled"""
        pagination_conf = copy_deepcopy(self.sara_search_plugin.config.pagination)
        self.sara_search_plugin.config.pagination["next_page_token_key"] = "cursor"

        prep = PreparedSearch(limit=None, count=False)
        prep.query_string = "startDate=2020-08-08&completionDate=2020-08-16"
        prep.sort_by_qs = "&sortParam=startDate&sortOrder=asc"

        try:
            urls, total_results = self.sara_search_plugin.collect_search_urls(
                prep,
                collection=self.collection,
            )
        finally:
            self.sara_search_plugin.config.pagination = pagination_conf

        expected_endpoint = self.sara_search_plugin.config.api_endpoint.rstrip(
            "/"
        ).format(_collection=self.collection)

        self.assertEqual(
            [
                f"{expected_endpoint}?startDate=2020-08-08&completionDate=2020-08-16"
                "&sortParam=startDate&sortOrder=asc"
            ],
            urls,
        )
        self.assertIsNone(total_results)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_count_and_search_cop_dataspace(
        self, mock__request
    ):
        self.maxDiff = None
        """A query with a QueryStringSearch (sara here) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        with open(self.provider_resp_dir / "sara_search.json") as f:
            sara_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            sara_resp_search,
        ]
        products = self.sara_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth_plugin=self.sara_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        sara_url_search = (
            "https://copernicus.nci.org.au/sara.server/1.0/api/collections/S2/search.json?startDate=2020-08-08&"
            "completionDate=2020-08-16&geometry=POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, "
            "153.7491 13.1342, 137.7729 13.1342))&productType=S2MSIL1C&instrument=MSI&processingLevel=L1C&"
            "sortParam=startDate&sortOrder=asc&maxRecords=2&page=1"
        )
        sara_products_count = 47
        number_of_products = 2

        mock__request.assert_called_once()
        self.assertEqual(mock__request.call_args_list[-1][0][1].url, sara_url_search)

        self.assertEqual(products.number_matched, sara_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.count_hits", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_no_count_and_search_cop_dataspace(
        self, mock__request, mock_count_hits
    ):
        """A query with a QueryStringSearch (here sara) without a count"""
        with open(self.provider_resp_dir / "sara_search.json") as f:
            sara_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = sara_resp_search
        products = self.sara_search_plugin.query(
            prep=PreparedSearch(
                count=False,
                page=1,
                limit=2,
                auth_plugin=self.sara_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        sara_url_search = (
            "https://copernicus.nci.org.au/sara.server/1.0/api/collections/S2/search.json?startDate=2020-08-08&"
            "completionDate=2020-08-16&geometry=POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, "
            "153.7491 13.1342, 137.7729 13.1342))&productType=S2MSIL1C&instrument=MSI&processingLevel=L1C&"
            "sortParam=startDate&sortOrder=asc&maxRecords=2&page=1"
        )
        number_of_products = 2

        mock_count_hits.assert_not_called()
        mock__request.assert_called_once()
        self.assertEqual(mock__request.call_args_list[-1][0][1].url, sara_url_search)

        self.assertIsNone(products.number_matched)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_search_cloudcover_cop_dataspace(
        self, mock__request, mock_normalize_results
    ):
        """A query with a QueryStringSearch (here sara) must use cloudCover filtering when configured"""

        self.sara_search_plugin.query(collection="S2_MSI_L1C", **{"eo:cloud_cover": 50})
        mock__request.assert_called()
        self.assertIn("cloudCover", mock__request.call_args_list[-1][0][1].url)

    def test_plugins_search_querystringsearch_search_cop_dataspace_ko(self):
        """A query with a parameter which is not queryable must
        raise an error if the provider does not allow it"""  # noqa
        # with raised error parameter set to True in the global config of the provider
        provider_search_plugin_config = copy_deepcopy(self.sara_search_plugin.config)
        self.sara_search_plugin.config.discover_metadata[
            "raise_mtd_discovery_error"
        ] = True

        with self.assertRaises(ValidationError) as context:
            self.sara_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth_plugin=self.sara_auth_plugin,
                ),
                **{**self.search_criteria_s2_msi_l1c, **{"foo": "bar"}},
            )
        self.assertEqual(
            "Search parameters which are not queryable are disallowed for this collection on this provider: "
            f"please remove 'foo' from your search parameters. Collection: "
            f"{self.search_criteria_s2_msi_l1c['collection']} / provider : {self.sara_search_plugin.provider}",
            context.exception.message,
        )

        # with raised error parameter set to True in the config of the collection of the provider

        # first, update this parameter to False in the global config
        # to show that it is going to be taken over by this new config
        self.sara_search_plugin.config.discover_metadata[
            "raise_mtd_discovery_error"
        ] = False

        self.sara_search_plugin.config.products[
            self.search_criteria_s2_msi_l1c["collection"]
        ]["discover_metadata"] = {"raise_mtd_discovery_error": True}

        with self.assertRaises(ValidationError) as context:
            self.sara_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth_plugin=self.sara_auth_plugin,
                ),
                **{**self.search_criteria_s2_msi_l1c, **{"foo": "bar"}},
            )
        self.assertEqual(
            "Search parameters which are not queryable are disallowed for this collection on this provider: "
            f"please remove 'foo' from your search parameters. Collection: "
            f"{self.search_criteria_s2_msi_l1c['collection']} / provider : {self.sara_search_plugin.provider}",
            context.exception.message,
        )

        # restore the original config
        self.sara_search_plugin.config = provider_search_plugin_config

    @mock.patch("eodag.plugins.search.qssearch.requests.Session.get", autospec=True)
    def test_plugins_search_querystringsearch_search_quota_exceeded(
        self, mock__request
    ):
        """A query with a QueryStringSearch must handle a 429 response returned by the provider"""
        response = MockResponse({}, status_code=429)
        mock__request.side_effect = requests.exceptions.HTTPError(response=response)
        prep = PreparedSearch(collection="S2_MSI_L1C", count=False)
        with self.assertRaises(QuotaExceededError):
            self.sara_search_plugin.query(
                prep=prep, collection="S2_MSI_L1C", **{"eo:cloud_cover": 50}
            )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_discover_collections(self, mock__request):
        """QueryStringSearch.discover_collections must return a well formatted dict"""
        # One of the providers that has a QueryStringSearch Search plugin and discover_collections configured
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        # change onfiguration for this test to filter out some collections
        results_entry = search_plugin.config.discover_collections["results_entry"]
        search_plugin.config.discover_collections["results_entry"] = cached_parse(
            'collections[?billing=="free"]'
        )

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = {
            "collections": [
                {
                    "id": "foo_collection",
                    "title": "The FOO collection",
                    "billing": "free",
                },
                {
                    "id": "bar_collection",
                    "title": "The BAR non-free collection",
                    "billing": "non-free",
                },
            ]
        }
        conf_update_dict = search_plugin.discover_collections()
        self.assertIn("foo_collection", conf_update_dict["providers_config"])
        self.assertIn("foo_collection", conf_update_dict["collections_config"])
        self.assertNotIn("bar_collection", conf_update_dict["providers_config"])
        self.assertNotIn("bar_collection", conf_update_dict["collections_config"])
        self.assertEqual(
            conf_update_dict["providers_config"]["foo_collection"]["_collection"],
            "foo_collection",
        )
        self.assertEqual(
            conf_update_dict["collections_config"]["foo_collection"]["title"],
            "The FOO collection",
        )
        # restore configuration
        search_plugin.config.discover_collections["results_entry"] = results_entry

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_discover_collections_unparsable_metadata_mapping(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must merge unparsable metadata_mapping with default"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        # backup and set unparsable_properties with a metadata_mapping override
        discover_collections_conf = copy_deepcopy(
            search_plugin.config.discover_collections
        )
        from eodag.api.product.metadata_mapping import (
            mtd_cfg_as_conversion_and_querypath,
        )

        search_plugin.config.discover_collections[
            "generic_collection_unparsable_properties"
        ] = {
            "metadata_mapping": mtd_cfg_as_conversion_and_querypath(
                {"eo:cloud_cover": "$.null"}
            )
        }

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = {
            "collections": [
                {
                    "id": "foo_collection",
                    "title": "The FOO collection",
                },
            ]
        }
        conf_update_dict = search_plugin.discover_collections()
        providers_config = conf_update_dict["providers_config"]["foo_collection"]

        # unparsable metadata_mapping should be merged with default metadata_mapping
        self.assertIn("metadata_mapping", providers_config)
        merged_mapping = providers_config["metadata_mapping"]

        # should contain the override from unparsable_properties
        self.assertIn("eo:cloud_cover", merged_mapping)

        # should also contain keys from the default metadata_mapping
        self.assertIn("id", merged_mapping)
        self.assertIn("geometry", merged_mapping)
        self.assertIn("start_datetime", merged_mapping)

        # restore configuration
        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_paginated(self):
        """QueryStringSearch.discover_collections must handle pagination"""
        # One of the providers that has a QueryStringSearch Search plugin and discover_collections configured
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        # change configuration for this test to filter out some collections
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections[
            "fetch_url"
        ] = "https://foo.bar/collections"
        search_plugin.config.discover_collections[
            "next_page_url_tpl"
        ] = "{url}?page={page}"
        search_plugin.config.discover_collections["start_page"] = 0

        with responses.RequestsMock(
            assert_all_requests_are_fired=True
        ) as mock_requests_post:
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=0",
                json={
                    "collections": [
                        {
                            "id": "foo_collection",
                            "title": "The FOO collection",
                            "billing": "free",
                        }
                    ]
                },
            )
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=1",
                json={
                    "collections": [
                        {
                            "id": "bar_collection",
                            "title": "The BAR non-free collection",
                            "billing": "non-free",
                        },
                    ]
                },
            )
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=2",
                json={"collections": []},
            )
            conf_update_dict = search_plugin.discover_collections()
            self.assertIn("foo_collection", conf_update_dict["providers_config"])
            self.assertIn("foo_collection", conf_update_dict["collections_config"])
            self.assertIn("bar_collection", conf_update_dict["providers_config"])
            self.assertIn("bar_collection", conf_update_dict["collections_config"])
            self.assertEqual(
                conf_update_dict["providers_config"]["foo_collection"]["_collection"],
                "foo_collection",
            )
            self.assertEqual(
                conf_update_dict["collections_config"]["foo_collection"]["title"],
                "The FOO collection",
            )

        # restore configuration
        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_without_fetch_url(
        self,
    ):
        """QueryStringSearch.discover_collections must handle missing fetch_url"""
        # One of the providers that has a QueryStringSearch Search plugin and discover_collections configured
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections.pop("fetch_url", None)

        response = search_plugin.discover_collections()
        self.assertIsNone(response)
        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_paginated_qs_dict(
        self,
    ):
        """
        QueryStringSearch.discover_collections must handle paginated responses with query string parameters
        """
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        # change configuration for this test to filter out some collections
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections[
            "fetch_url"
        ] = "https://foo.bar/collections"
        search_plugin.config.discover_collections[
            "next_page_url_tpl"
        ] = "{url}?page={page}"
        search_plugin.config.discover_collections["start_page"] = 0
        search_plugin.config.discover_collections[
            "single_collection_fetch_qs"
        ] = "foo=bar"

        with responses.RequestsMock(
            assert_all_requests_are_fired=True
        ) as mock_requests_post:
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=0&foo=bar",
                json={
                    "collections": [
                        {
                            "id": "foo_collection",
                            "title": "The FOO collection",
                            "billing": "free",
                        }
                    ]
                },
            )
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=1&foo=bar",
                json={
                    "collections": [
                        {
                            "id": "bar_collection",
                            "title": "The BAR non-free collection",
                            "billing": "non-free",
                        },
                    ]
                },
            )
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=2&foo=bar",
                json={"collections": []},
            )
            conf_update_dict = search_plugin.discover_collections()
            self.assertIn("foo_collection", conf_update_dict["providers_config"])
            self.assertIn("foo_collection", conf_update_dict["collections_config"])
            self.assertIn("bar_collection", conf_update_dict["providers_config"])
            self.assertIn("bar_collection", conf_update_dict["collections_config"])
            self.assertEqual(
                conf_update_dict["providers_config"]["foo_collection"]["_collection"],
                "foo_collection",
            )
            self.assertEqual(
                conf_update_dict["collections_config"]["foo_collection"]["title"],
                "The FOO collection",
            )

        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_per_page_no_fetch_url(
        self,
    ):
        """QueryStringSearch.discover_collections must handle paginated responses with query string parameters"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections.pop("fetch_url")
        search_plugin.config.discover_collections[
            "next_page_url_tpl"
        ] = "{url}?page={page}"
        search_plugin.config.discover_collections["start_page"] = 0
        result = search_plugin.discover_collections_per_page()
        assert result is None

        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_per_page_keyerror(
        self,
    ):
        """QueryStringSearch.discover_collections must handle missing keys in the response"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections = {
            "result_type": "json",
            "results_entry": parse("$.collections"),
        }

        class DummyResponse:
            def json(self):
                return {"collections": [{}]}

        with mock.patch.object(
            QueryStringSearch, "_request", return_value=DummyResponse()
        ):
            with self.assertLogs(level="WARNING") as log:
                result = search_plugin.discover_collections_per_page(
                    fetch_url="https://foo.bar/collections"
                )
                assert result is None
                assert any("Incomplete" in m for m in log.output)
        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_per_page_request_exception(
        self,
    ):
        """QueryStringSearch.discover_collections must handle request exceptions"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections = {
            "result_type": "json",
            "results_entry": JSONPath(),
        }

        class DummyResponse:
            def json(self):
                raise requests.RequestException("boom")

        with mock.patch.object(
            QueryStringSearch, "_request", return_value=DummyResponse()
        ):
            with self.assertLogs(level="DEBUG") as log:
                result = search_plugin.discover_collections_per_page(
                    fetch_url="https://foo.bar/collections"
                )
                assert result is None
                assert any(
                    "Could not parse discovered collections response" in m
                    for m in log.output
                )

        search_plugin.config.discover_collections = discover_collections_conf

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_querystringsearch_discover_collections_post(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must be able to query using POST requests"""
        # One of the providers that has a QueryStringSearch.discover_collections configured with POST requests
        provider = "geodes"
        search_plugin = self.get_search_plugin(self.collection, provider)

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = {
            "collections": [
                {
                    "id": "foo_collection",
                    "title": "The FOO collection",
                },
                {
                    "id": "bar_collection",
                    "title": "The BAR collection",
                },
            ]
        }
        conf_update_dict = search_plugin.discover_collections()
        self.assertIn("foo_collection", conf_update_dict["providers_config"])
        self.assertIn("foo_collection", conf_update_dict["collections_config"])
        self.assertIn("bar_collection", conf_update_dict["providers_config"])
        self.assertIn("bar_collection", conf_update_dict["collections_config"])
        self.assertEqual(
            conf_update_dict["providers_config"]["foo_collection"]["_collection"],
            "foo_collection",
        )
        self.assertEqual(
            conf_update_dict["collections_config"]["foo_collection"]["title"],
            "The FOO collection",
        )

    @mock.patch("eodag.plugins.search.qssearch.requests.Session.get", autospec=True)
    def test_plugins_search_querystringsearch_discover_collections_with_query_param(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must return a well formatted dict"""
        # One of the providers that has discover_collections() configured with QueryStringSearch
        provider = "wekeo_cmems"
        search_plugin = self.get_search_plugin(provider=provider)
        self.assertEqual("PostJsonSearch", search_plugin.__class__.__name__)
        self.assertEqual(
            "QueryStringSearch",
            search_plugin.discover_collections.__func__.__qualname__.split(".")[0],
        )

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "dataset_id": "foo_collection",
                        "metadata": {"title": "The FOO collection"},
                    }
                ]
            },
            {
                "dataset_id": "foo_collection",
                "metadata": {"title": "The FOO collection"},
            },
        ]
        search_plugin.discover_collections()
        mock__request.assert_called_with(
            mock.ANY,
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/datasets/foo_collection",
            timeout=60,
            headers=USER_AGENT,
            verify=True,
        )

    @mock.patch("eodag.plugins.search.qssearch.requests.Session.get", autospec=True)
    def test_plugins_search_querystringsearch_discover_collections_with_id_to_rename(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must handle collections that have to be renamed"""
        # One of the providers that has discover_collections() configured with QueryStringSearch
        provider = "wekeo_cmems"
        search_plugin = self.get_search_plugin(provider=provider)
        # change configuration for this test to rename the collection id
        search_plugin.config.discover_collections[
            "single_collection_parsable_metadata"
        ]["id"] = (None, cached_parse("$.metadata.understandable_id"))

        # case where the name to replace the current id exists in the metadata
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "dataset_id": "1a2b3c4d",
                        "metadata": {"title": "The FOO collection"},
                    }
                ]
            },
            {
                "dataset_id": "1a2b3c4d",
                "metadata": {
                    "title": "The FOO collection",
                    "understandable_id": "foo_collection",
                },
            },
        ]

        with self.assertLogs(level="DEBUG") as cm:
            conf_update_dict = search_plugin.discover_collections()
            self.assertIn(
                "Rename 1a2b3c4d collection to foo_collection", str(cm.output)
            )

        self.assertIn("foo_collection", conf_update_dict["providers_config"])
        self.assertIn("foo_collection", conf_update_dict["collections_config"])
        self.assertNotIn("1a2b3c4d", conf_update_dict["providers_config"])
        self.assertNotIn("1a2b3c4d", conf_update_dict["collections_config"])
        self.assertEqual(
            conf_update_dict["providers_config"]["foo_collection"]["collection"],
            "1a2b3c4d",
        )
        self.assertNotIn("id", conf_update_dict["collections_config"]["foo_collection"])

        # case where the name to replace the current id does not exist in the metadata
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "dataset_id": "5e6f7g8h",
                        "metadata": {"title": "The BAR collection"},
                    }
                ]
            },
            {
                "dataset_id": "5e6f7g8h",
                "metadata": {"title": "The BAR collection"},
            },
        ]

        conf_update_dict = search_plugin.discover_collections()

        self.assertNotIn("5e6f7g8h", conf_update_dict["providers_config"])
        self.assertNotIn("5e6f7g8h", conf_update_dict["collections_config"])

        # restore configuration
        del search_plugin.config.discover_collections[
            "single_collection_parsable_metadata"
        ]["id"]

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_discover_collections_keywords(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must return a dict with well formatted keywords"""
        # One of the providers that has a QueryStringSearch Search plugin and keywords configured
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = {
            "collections": [
                {
                    "id": "foo_collection",
                    "keywords": ["foo", "bar"],
                    "summaries": {
                        "instruments": ["baZ"],
                        "constellation": "qux,foo",
                        "platform": ["quux", "corge", "bar"],
                        "processing:level": "GRAULT",
                    },
                },
            ]
        }
        conf_update_dict = search_plugin.discover_collections()
        keywords_list = conf_update_dict["collections_config"]["foo_collection"][
            "keywords"
        ]

        self.assertEqual(
            [
                "bar",
                "baz",
                "corge",
                "foo",
                "foo-collection",
                "grault",
                "quux",
                "qux",
            ],
            keywords_list,
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_distinct_collection_mtd_mapping(
        self, mock__request
    ):
        """The metadata mapping for QueryStringSearch should not mix specific collections metadata-mapping"""
        geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__
        mock__request.return_value = mock.Mock()
        result = {
            "totalResults": 1,
            "features": [
                {
                    "id": "foo",
                    "geometry": geojson_geometry,
                },
            ],
        }
        mock__request.return_value.json.side_effect = [result, result]
        search_plugin = self.get_search_plugin(self.collection, "sara")

        # ensure metadata_mapping exists for S1_SAR_GRD and S1_SAR_SLC
        search_plugin.config.products["S1_SAR_GRD"].setdefault("metadata_mapping", {})
        search_plugin.config.products["S1_SAR_SLC"].setdefault("metadata_mapping", {})

        # update metadata_mapping only for S1_SAR_GRD
        search_plugin.config.products["S1_SAR_GRD"]["metadata_mapping"]["bar"] = (
            None,
            "baz",
        )
        products = search_plugin.query(
            collection="S1_SAR_GRD",
            auth=None,
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "baz")

        # search with another collection
        self.assertNotIn(
            "bar", search_plugin.config.products["S1_SAR_SLC"]["metadata_mapping"]
        )
        products = search_plugin.query(
            collection="S1_SAR_SLC",
            auth=None,
        )
        self.assertNotIn("bar", products.data[0].properties)

    @mock.patch(
        "eodag.plugins.search.qssearch.requests.Session.get",
        autospec=True,
        side_effect=requests.exceptions.Timeout(),
    )
    def test_plugins_search_querystringseach_timeout(self, mock__request):
        search_plugin = self.get_search_plugin(self.collection, "sara")
        with self.assertRaises(TimeOutError):
            search_plugin.query(
                collection="S1_SAR_SLC",
                auth=None,
            )

    def test_plugins_search_querystringsearch_count_hits_xml(self):
        """Test QueryStringSearch.count_hits() with XML response and XPath key path"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        search_plugin.config.pagination = {
            "total_items_nb_key_path": "string(//ns:TotalResults)"
        }
        xml = (
            """<root xmlns="http://example.com"><TotalResults>4</TotalResults></root>"""
        )

        mock_response = mock.Mock()
        mock_response.content = xml.encode()

        with mock.patch.object(search_plugin, "_request", return_value=mock_response):
            result = search_plugin.count_hits("http://fake.url", result_type="xml")
            assert result == 4

    def test_plugins_search_querystringsearch_count_hits_json_dict_ok(self):
        """Test QueryStringSearch.count_hits() with JSON response and JSONPath key path"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        search_plugin.config.pagination = {"total_items_nb_key_path": parse("$.total")}

        mock_response = mock.Mock()
        mock_response.json.return_value = {"total": 99}

        with mock.patch.object(search_plugin, "_request", return_value=mock_response):
            result = search_plugin.count_hits("http://fake.url")
            assert result == 99

    def test_plugins_search_querystringsearch_count_hits_json_dict_not_jsonpath(self):
        """Test QueryStringSearch.count_hits() with JSON response and non-JSONPath key path"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        search_plugin.config.pagination = {"total_items_nb_key_path": "$.total"}

        mock_response = mock.Mock()
        mock_response.json.return_value = {"total": 99}

        with mock.patch.object(search_plugin, "_request", return_value=mock_response):
            with pytest.raises(PluginImplementationError):
                search_plugin.count_hits("http://fake.url")

    def test_plugins_search_querystringsearch_count_hits_json_dict_jsonpath_not_found(
        self,
    ):
        """Test QueryStringSearch.count_hits() with JSON response and JSONPath key path not found in the response"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        search_plugin.config.pagination = {
            "total_items_nb_key_path": parse("$.missing")
        }

        mock_response = mock.Mock()
        mock_response.json.return_value = {"total": 99}

        with mock.patch.object(search_plugin, "_request", return_value=mock_response):
            with pytest.raises(MisconfiguredError):
                search_plugin.count_hits("http://fake.url")


class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginPostJsonSearch, self).setUp()
        # One of the providers that has a PostJsonSearch Search plugin
        provider = "aws_eos"
        self.awseos_search_plugin = self.get_search_plugin(self.collection, provider)
        self.awseos_auth_plugin = self.get_auth_plugin(self.awseos_search_plugin)
        self.awseos_auth_plugin.config.credentials = dict(apikey="dummyapikey")
        self.awseos_url = "https://gate.eos.com/api/lms/search/v2/sentinel2"

    def test_plugins_search_postjsonsearch_request_error(self):
        """A query with a PostJsonSearch must handle requests errors"""

        @responses.activate(registry=responses.registries.FirstMatchRegistry)
        def run():
            responses.add(
                responses.POST, self.awseos_url, status=500, body=b"test error message"
            )

            with self.assertLogs("eodag.search.qssearch", level="DEBUG") as cm:
                with self.assertRaises(RequestError):
                    products, estimate = self.awseos_search_plugin.query(
                        prep=PreparedSearch(
                            page=1,
                            limit=2,
                            auth_plugin=self.awseos_auth_plugin,
                        ),
                        raise_errors=True,
                        **self.search_criteria_s2_msi_l1c,
                    )
                self.assertIn("test error message", str(cm.output))

        run()

    def test_plugins_search_postjsonsearch_request_auth_error(self):
        """A query with a PostJsonSearch must handle auth errors"""

        @responses.activate(registry=responses.registries.FirstMatchRegistry)
        def run():
            responses.add(
                responses.POST, self.awseos_url, status=403, body=b"test error message"
            )

            with self.assertRaisesRegex(AuthenticationError, "test error message"):
                products, estimate = self.awseos_search_plugin.query(
                    prep=PreparedSearch(
                        page=1,
                        limit=2,
                        auth_plugin=self.awseos_auth_plugin,
                    ),
                    **self.search_criteria_s2_msi_l1c,
                )

        run()

    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    def test_plugins_search_postjsonsearch_search_quota_exceeded(self, mock__request):
        """A query with a PostJsonSearch must handle a 429 response returned by the provider"""

        class MockResponseRequestsException(MockResponse):
            def raise_for_status(self):
                if self.status_code != 200:
                    raise requests.RequestException()

        response = MockResponseRequestsException({}, status_code=429)
        mock__request.return_value = response
        with self.assertRaises(QuotaExceededError):
            self.awseos_search_plugin.query(collection="S2_MSI_L2A")

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_count_and_search_awseos(self, mock__request):
        """A query with a PostJsonSearch (here aws_eos) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        with open(self.provider_resp_dir / "awseos_search.json") as f:
            awseos_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            awseos_resp_search,
        ]
        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth_plugin=self.awseos_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        awseos_products_count = 44
        number_of_products = 2

        self.assertEqual(mock__request.call_args_list[-1][0][1].url, self.awseos_url)

        self.assertEqual(products.number_matched, awseos_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_count_and_search_awseos_s2l2a(
        self, mock__request
    ):
        """A query with a PostJsonSearch (here aws_eos) must return a single EOProduct when search by id using specific_qssearch"""  # noqa

        mock_values = []
        with open(self.provider_resp_dir / "s2l2a_tileInfo.json") as f:
            mock_values.append(json.load(f))
        with open(self.provider_resp_dir / "s2l2a_productInfo.json") as f:
            mock_values.append(json.load(f))

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = mock_values

        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin, count=True),
            **{
                "collection": "S2_MSI_L2A",
                "id": "S2B_MSIL2A_20220101T000459_N0301_R130_T53DMB_20220101T012649",
            },
        )

        self.assertEqual(mock__request.call_count, 2)
        self.assertEqual(
            mock__request.call_args_list[0][0][1].url,
            "https://roda.sentinel-hub.com/sentinel-s2-l2a/tiles/53/D/MB/2022/1/1/0/tileInfo.json",
        )
        self.assertEqual(
            mock__request.call_args_list[1][0][1].url,
            "https://roda.sentinel-hub.com/sentinel-s2-l2a/tiles/53/D/MB/2022/1/1/0/productInfo.json",
        )

        self.assertEqual(len(products.data), 1)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.count_hits", autospec=True
    )
    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_no_count_and_search_awseos(
        self, mock__request, mock_count_hits
    ):
        """A query with a PostJsonSearch (here aws_eos) without a count"""
        with open(self.provider_resp_dir / "awseos_search.json") as f:
            awseos_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = awseos_resp_search
        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(
                count=False,
                page=1,
                limit=2,
                auth_plugin=self.awseos_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        number_of_products = 2

        mock_count_hits.assert_not_called()
        self.assertEqual(mock__request.call_args_list[0][0][1].url, self.awseos_url)

        self.assertIsNone(products.number_matched)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)
        # products count should not have been extracted from search results
        self.assertIsNone(
            getattr(mock__request.call_args_list[0][0][1], "total_items_nb", None)
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
        autospec=True,
    )
    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    def test_plugins_search_postjsonsearch_search_cloudcover_awseos(
        self, mock_requests_post, mock_normalize_results
    ):
        """A query with a PostJsonSearch (here aws_eos) must only use cloudCover filtering for non-radar collections"""  # noqa

        self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin),
            collection="S2_MSI_L1C",
            **{"eo:cloud_cover": 50},
        )
        mock_requests_post.assert_called()
        self.assertIn("cloudCoverage", str(mock_requests_post.call_args_list[-1][1]))
        mock_requests_post.reset_mock()

        self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin),
            collection="S1_SAR_GRD",
            **{"eo:cloud_cover": 50},
        )
        mock_requests_post.assert_called()
        self.assertNotIn(
            "cloudCoverPercentage", str(mock_requests_post.call_args_list[-1][1])
        )

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_distinct_collection_mtd_mapping(
        self, mock__request
    ):
        """The metadata mapping for PostJsonSearch should not mix specific collections metadata-mapping"""
        geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__
        mock__request.return_value = mock.Mock()
        result = {
            "meta": {"page": 1, "found": 1, "limit": 2},
            "results": [
                {
                    "id": "foo",
                    "dataGeometry": geojson_geometry,
                },
            ],
        }
        mock__request.return_value.json.side_effect = [result, result]

        # update metadata_mapping only for S1_SAR_GRD
        self.awseos_search_plugin.config.products["S1_SAR_GRD"]["metadata_mapping"][
            "bar"
        ] = (
            None,
            "baz",
        )
        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin),
            collection="S1_SAR_GRD",
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "baz")

        # search with another collection
        self.assertNotIn(
            "bar",
            self.awseos_search_plugin.config.products["S2_MSI_L1C"]["metadata_mapping"],
        )
        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin),
            collection="S2_MSI_L1C",
        )
        self.assertNotIn("bar", products.data[0].properties)

    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    @mock.patch(
        "eodag.plugins.search.qssearch.PostJsonSearch.normalize_results", autospec=True
    )
    def test_plugins_search_postjsonsearch_default_dates(
        self, mock_normalize, mock_request
    ):
        provider = "wekeo_ecmwf"
        search_plugins = self.plugins_manager.get_search_plugins(provider=provider)
        search_plugin = next(search_plugins)
        mock_request.return_value = MockResponse({"features": []}, 200)
        # year, month, day, time given -> don't use default dates
        search_plugin.query(
            prep=PreparedSearch(),
            collection="ERA5_SL",
            **{
                "ecmwf:year": "2020",
                "ecmwf:month": ["02"],
                "ecmwf:day": ["20", "21"],
                "ecmwf:time": ["01:00"],
            },
        )
        mock_request.assert_called_with(
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
            json={
                "year": "2020",
                "month": ["02"],
                "day": ["20", "21"],
                "time": ["01:00"],
                "dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
                "itemsPerPage": 20,
                "startIndex": 0,
            },
            headers=USER_AGENT,
            timeout=60,
            verify=True,
        )
        # start date given and converted to year, month, day, time
        search_plugin.query(
            prep=PreparedSearch(),
            collection="ERA5_SL",
            start_datetime="2021-02-01T03:00:00Z",
        )
        mock_request.assert_called_with(
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
            json={
                "year": ["2021"],
                "month": ["02"],
                "day": ["01"],
                "time": ["03:00"],
                "dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
                "itemsPerPage": 20,
                "startIndex": 0,
            },
            headers=USER_AGENT,
            timeout=60,
            verify=True,
        )
        # no date info given -> default dates (extent.temporal.interval.0.0) which are
        # then converted to year, month, day, time
        col_conf = {
            "id": "ERA5_SL",
            "description": "ERA5 abstract",
            "instruments": [],
            "constellation": "ERA5",
            "platform": "ERA5",
            "processing:level": None,
            "keywords": [
                "ECMWF",
                "Reanalysis",
                "ERA5",
                "CDS",
                "Atmospheric",
                "land",
                "sea",
                "hourly",
                "single",
                "levels",
            ],
            "eodag:sensor_type": "ATMOSPHERIC",
            "license": "other",
            "title": "ERA5 hourly data on single levels from 1940 to present",
            "extent": {
                "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]},
                "temporal": {"interval": [["1940-01-01T00:00:00Z", None]]},
            },
            "_id": "ERA5_SL",
        }
        search_plugin.config.collection_config = dict(
            col_conf,
            **{"_collection": "ERA5_SL"},
        )
        search_plugin.query(collection="ERA5_SL", prep=PreparedSearch())
        mock_request.assert_called_with(
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
            json={
                "dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
                "itemsPerPage": 20,
                "startIndex": 0,
            },
            headers=USER_AGENT,
            timeout=60,
            verify=True,
        )
        # collection with dates are query params -> use extent.temporal.interval.0.0 and today
        col_conf = {
            "id": "CAMS_EAC4",
            "description": "CAMS_EAC4 abstract",
            "instruments": [],
            "constellation": "CAMS",
            "platform": "CAMS",
            "processing:level": None,
            "keywords": [
                "Copernicus",
                "ADS",
                "CAMS",
                "Atmosphere",
                "Atmospheric",
                "EWMCF",
                "EAC4",
            ],
            "eodag:sensor_type": "ATMOSPHERIC",
            "license": "other",
            "title": "CAMS global reanalysis (EAC4)",
            "extent": {
                "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]},
                "temporal": {"interval": [["2003-01-01T00:00:00Z", None]]},
            },
            "_id": "CAMS_EAC4",
        }
        search_plugin.config.collection_config = dict(
            col_conf,
            **{"_collection": "CAMS_EAC4"},
        )
        search_plugin.query(collection="CAMS_EAC4", prep=PreparedSearch())
        mock_request.assert_called_with(
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
            json={
                "dataset_id": "EO:ECMWF:DAT:CAMS_GLOBAL_REANALYSIS_EAC4",
                "itemsPerPage": 20,
                "startIndex": 0,
            },
            headers=USER_AGENT,
            timeout=60,
            verify=True,
        )

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_query_params_wekeo(self, mock__request):
        """A query with PostJsonSearch (here wekeo) must generate query params corresponding to the
        search criteria"""
        provider = "wekeo_ecmwf"
        collection = "GRIDDED_GLACIERS_MASS_CHANGE"
        search_plugin = self.get_search_plugin(collection, provider)
        auth_plugin = self.get_auth_plugin(search_plugin)

        mock__request.return_value = mock.Mock()

        def _test_query_params(search_criteria, raw_result, expected_query_params):
            mock__request.reset_mock()
            mock__request.return_value.json.side_effect = [raw_result]
            search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=10,
                    auth_plugin=auth_plugin,
                ),
                **search_criteria,
            )
            self.assertDictEqual(
                mock__request.call_args_list[0].args[1].query_params,
                expected_query_params,
            )

        raw_result = {
            "properties": {"itemsPerPage": 1, "startIndex": 0, "totalResults": 1},
            "features": [
                {
                    "type": "Feature",
                    "properties": {
                        "startdate": "1975-01-01T00:00:00Z",
                        "enddate": "2024-09-30T00:00:00Z",
                    },
                    "id": "derived-gridded-glacier-mass-change-576f8a153a25a83d9d3a5cfb03c4a759",
                }
            ],
        }

        # Test #1: using the datetime
        search_criteria = {
            "collection": collection,
            "start_datetime": "1980-01-01",
            "end_datetime": "1981-12-31",
            "ecmwf:variable": "glacier_mass_change",
            "ecmwf:data_format": "zip",
            "ecmwf:product_version": "wgms_fog_2022_09",
        }
        expected_query_params = {
            "dataset_id": "EO:ECMWF:DAT:DERIVED_GRIDDED_GLACIER_MASS_CHANGE",
            "hydrological_year": ["1980_81"],
            "variable": "glacier_mass_change",
            "data_format": "zip",
            "product_version": "wgms_fog_2022_09",
            "itemsPerPage": 10,
            "startIndex": 0,
        }
        _test_query_params(search_criteria, raw_result, expected_query_params)

        # Test #2: using parameter hydrological_year (single value)
        search_criteria = {
            "collection": collection,
            "ecmwf:variable": "glacier_mass_change",
            "ecmwf:data_format": "zip",
            "ecmwf:product_version": "wgms_fog_2022_09",
            "ecmwf:hydrological_year": ["2020_21"],
        }
        expected_query_params = {
            "dataset_id": "EO:ECMWF:DAT:DERIVED_GRIDDED_GLACIER_MASS_CHANGE",
            "hydrological_year": ["2020_21"],
            "variable": "glacier_mass_change",
            "data_format": "zip",
            "product_version": "wgms_fog_2022_09",
            "itemsPerPage": 10,
            "startIndex": 0,
        }
        _test_query_params(search_criteria, raw_result, expected_query_params)

        # Test #3: using parameter hydrological_year (multiple values)
        search_criteria = {
            "collection": collection,
            "ecmwf:variable": "glacier_mass_change",
            "ecmwf:data_format": "zip",
            "ecmwf:product_version": "wgms_fog_2022_09",
            "ecmwf:hydrological_year": ["1990_91", "2020_21"],
        }
        expected_query_params = {
            "dataset_id": "EO:ECMWF:DAT:DERIVED_GRIDDED_GLACIER_MASS_CHANGE",
            "hydrological_year": ["1990_91", "2020_21"],
            "variable": "glacier_mass_change",
            "data_format": "zip",
            "product_version": "wgms_fog_2022_09",
            "itemsPerPage": 10,
            "startIndex": 0,
        }
        _test_query_params(search_criteria, raw_result, expected_query_params)


class TestSearchPluginODataV4Search(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginODataV4Search, self).setUp()

        # manually add conf as this provider is not supported any more
        onda_config_dict = cached_yaml_load_all(
            Path(TEST_RESOURCES_PATH) / "onda_conf.yml"
        )[0]
        # Provider YAML now uses provider name as top-level key
        provider_name, onda_config = next(iter(onda_config_dict.items()))
        if "name" not in onda_config:
            onda_config["name"] = provider_name
        self.plugins_manager.providers["onda"] = Provider(onda_config)
        self.plugins_manager.rebuild()

        # One of the providers that has a ODataV4Search Search plugin
        provider = "onda"
        self.onda_search_plugin = self.get_search_plugin(self.collection, provider)
        self.onda_auth_plugin = self.get_auth_plugin(self.onda_search_plugin)
        # Some expected results
        with open(self.provider_resp_dir / "onda_count.json") as f:
            self.onda_resp_count = json.load(f)
        self.onda_url_count = (
            'https://catalogue.onda-dias.eu/dias-catalogue/Products/$count?$search="footprint:"'
            "Intersects(POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, "
            '137.7729 13.1342)))" AND productType:S2MSI1C AND beginPosition:[2020-08-08T00:00:00.000Z TO *] '
            'AND endPosition:[* TO 2020-08-16T00:00:00.000Z] AND foo:bar"'
        )
        self.onda_products_count = 47

    def test_plugins_search_odatav4search_normalize_results_onda(self):
        """ODataV4Search.normalize_results must use metada pre-mapping if configured"""

        self.assertTrue(hasattr(self.onda_search_plugin.config, "metadata_pre_mapping"))
        self.assertDictEqual(
            self.onda_search_plugin.config.metadata_pre_mapping,
            {
                "metadata_path": cached_parse("$.Metadata"),
                "metadata_path_id": "id",
                "metadata_path_value": "value",
            },
        )
        self.assertEqual(
            self.onda_search_plugin.config.discover_metadata["metadata_path"],
            "$.Metadata.*",
        )

        raw_results = [
            {"Metadata": [{"id": "foo", "value": "bar"}], "footprint": "POINT (0 0)"}
        ]
        products = self.onda_search_plugin.normalize_results(raw_results)

        self.assertEqual(products[0].properties["onda:foo"], "bar")

    @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_count_and_search_onda(
        self, mock__request, mock_requests_get
    ):
        """A query with a ODataV4Search (here onda) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        # per_product_metadata_query parameter is updated to False if it is necessary
        per_product_metadata_query = (
            self.onda_search_plugin.config.per_product_metadata_query
        )
        if per_product_metadata_query:
            self.onda_search_plugin.config.per_product_metadata_query = False

        with open(self.provider_resp_dir / "onda_search.json") as f:
            onda_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            self.onda_resp_count,
            onda_resp_search,
        ]
        mock_requests_get.return_value = mock.Mock()
        # Mock requests.get in ODataV4Search.do_search that sends a request per product
        # obtained by QueryStringSearch.do_search to retrieve its metadata.
        # Just use dummy metadata here for the test.
        mock_requests_get.return_value.json.return_value = dict(
            value=[dict(id="dummy_metadata", value="dummy_metadata_val")]
        )
        products = self.onda_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth=self.onda_auth_plugin,
            ),
            # custom query argument that must be mapped using discovery_metata.search_param
            foo="bar",
            **self.search_criteria_s2_msi_l1c,
        )
        # restore per_product_metadata_query initial value if it is necessary
        if (
            self.onda_search_plugin.config.per_product_metadata_query
            != per_product_metadata_query
        ):
            self.onda_search_plugin.config.per_product_metadata_query = (
                per_product_metadata_query
            )

        # Specific expected results
        number_of_products = 2
        onda_url_search = (
            'https://catalogue.onda-dias.eu/dias-catalogue/Products?$format=json&$search="'
            'footprint:"Intersects(POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, '
            '137.7729 13.1342)))" AND productType:S2MSI1C AND beginPosition:[2020-08-08T00:00:00.000Z TO *] '
            "AND endPosition:[* TO 2020-08-16T00:00:00.000Z] "
            'AND foo:bar"&$orderby=beginPosition asc&$top=2&$skip=0&$expand=Metadata'
        )

        self.assertEqual(
            mock__request.call_args_list[0].args[1].url, self.onda_url_count
        )
        self.assertEqual(mock__request.call_args_list[1].args[1].url, onda_url_search)

        self.assertEqual(products.number_matched, self.onda_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)
        # products count non extracted from search results as count endpoint is specified
        self.assertFalse(hasattr(self.onda_search_plugin, "total_items_nb"))

    @mock.patch("eodag.plugins.search.qssearch.get_ssl_context", autospec=True)
    @mock.patch("eodag.plugins.search.qssearch.Request", autospec=True)
    @mock.patch("eodag.plugins.search.qssearch.urlopen", autospec=True)
    @mock.patch("eodag.plugins.search.qssearch.cast", autospec=True)
    def test_plugins_search_odatav4search_with_ssl_context(
        self, mock_cast, mock_urlopen, mock_request, mock_get_ssl_context
    ):
        """A query with a ODataV4Search (here onda) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        self.onda_search_plugin.config.ssl_verify = False
        mock_cast.return_value.json.return_value = 2

        ssl_ctx = ssl.create_default_context()
        ssl_ctx.check_hostname = False
        ssl_ctx.verify_mode = ssl.CERT_NONE

        mock_request.return_value = mock.Mock()

        # Mocking return value of get_ssl_context
        mock_get_ssl_context.return_value = ssl_ctx

        self.onda_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth=self.onda_auth_plugin,
            ),
            # custom query argument that must be mapped using discovery_metata.search_param
            foo="bar",
            **self.search_criteria_s2_msi_l1c,
        )

        mock_get_ssl_context.assert_called_with(False)

        # # Asserting that get_ssl_context has been called
        self.assertEqual(mock_get_ssl_context.call_count, 2)

        # Asserting that urlopen has been called with the correct arguments
        mock_urlopen.assert_called_with(
            mock_request.return_value, timeout=60, context=ssl_ctx
        )

        del self.onda_search_plugin.config.ssl_verify

    @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_count_and_search_onda_per_product_metadata_query(
        self, mock__request, mock_requests_get
    ):
        """A query with a ODataV4Search (here onda) must return tuple with a list of EOProduct and a number of available products for query per product metadata"""  # noqa
        # per_product_metadata_query parameter is updated to True if it is necessary
        per_product_metadata_query = (
            self.onda_search_plugin.config.per_product_metadata_query
        )
        if not per_product_metadata_query:
            self.onda_search_plugin.config.per_product_metadata_query = True

        with open(self.provider_resp_dir / "onda_search.json") as f:
            onda_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            self.onda_resp_count,
            onda_resp_search,
        ]
        mock_requests_get.return_value = mock.Mock()
        # Mock requests.get in ODataV4Search.do_search that sends a request per product
        # obtained by QueryStringSearch.do_search to retrieve its metadata.
        # Just use dummy metadata here for the test.
        mock_requests_get.return_value.json.return_value = dict(
            value=[dict(id="dummy_metadata", value="dummy_metadata_val")]
        )
        products = self.onda_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth=self.onda_auth_plugin,
            ),
            # custom query argument that must be mapped using discovery_metata.search_param
            foo="bar",
            **self.search_criteria_s2_msi_l1c,
        )
        # restore per_product_metadata_query initial value if it is necessary
        if (
            self.onda_search_plugin.config.per_product_metadata_query
            != per_product_metadata_query
        ):
            self.onda_search_plugin.config.per_product_metadata_query = (
                per_product_metadata_query
            )

        # we check request arguments of the last call
        metadata_url = "{}({})/Metadata".format(
            self.onda_search_plugin.config.api_endpoint.rstrip("/"),
            products.data[1].properties["uid"],
        )
        mock_requests_get.assert_called_with(
            metadata_url, headers=USER_AGENT, timeout=HTTP_REQ_TIMEOUT, verify=True
        )
        # we check that two requests have been called, one per product
        self.assertEqual(mock_requests_get.call_count, 2)

        # Specific expected results
        number_of_products = 2
        onda_url_search = (
            'https://catalogue.onda-dias.eu/dias-catalogue/Products?$format=json&$search="'
            'footprint:"Intersects(POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, '
            '137.7729 13.1342)))" AND productType:S2MSI1C AND beginPosition:[2020-08-08T00:00:00.000Z TO *] '
            "AND endPosition:[* TO 2020-08-16T00:00:00.000Z] "
            'AND foo:bar"&$orderby=beginPosition asc&$top=2&$skip=0&$expand=Metadata'
        )

        self.assertEqual(
            mock__request.call_args_list[0].args[1].url, self.onda_url_count
        )
        self.assertEqual(mock__request.call_args_list[1].args[1].url, onda_url_search)

        self.assertEqual(products.number_matched, self.onda_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)
        # products count non extracted from search results as count endpoint is specified
        self.assertFalse(hasattr(self.onda_search_plugin, "total_items_nb"))

    @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_count_and_search_onda_per_product_metadata_query_request_error(
        self, mock__request, mock_requests_get
    ):
        """A query with a ODataV4Search (here onda) must handle requests errors for query per product metadata"""  # noqa
        # per_product_metadata_query parameter is updated to True if it is necessary
        per_product_metadata_query = (
            self.onda_search_plugin.config.per_product_metadata_query
        )
        if not per_product_metadata_query:
            self.onda_search_plugin.config.per_product_metadata_query = True
        with open(self.provider_resp_dir / "onda_search.json") as f:
            onda_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            self.onda_resp_count,
            onda_resp_search,
        ]
        mock_requests_get.return_value = mock.Mock()
        # Mock requests.get in ODataV4Search.do_search that sends a request per product
        # obtained by QueryStringSearch.do_search to retrieve its metadata.
        # Just use dummy metadata here for the test.
        mock_requests_get.return_value.json.return_value = dict(
            value=[dict(id="dummy_metadata", value="dummy_metadata_val")]
        )
        mock_requests_get.side_effect = RequestException()

        with self.assertLogs(level="ERROR") as cm:
            self.onda_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth=self.onda_auth_plugin,
                ),
                # custom query argument that must be mapped using discovery_metata.search_param
                foo="bar",
                **self.search_criteria_s2_msi_l1c,
            )
            # restore per_product_metadata_query initial value if it is necessary
            if (
                self.onda_search_plugin.config.per_product_metadata_query
                != per_product_metadata_query
            ):
                self.onda_search_plugin.config.per_product_metadata_query = (
                    per_product_metadata_query
                )
            search_provider = self.onda_auth_plugin.provider
            search_type = self.onda_search_plugin.__class__.__name__
            error_message = f"Skipping error while searching for {search_provider} {search_type} instance"
            error_message_indexes_list = [
                i.start() for i in re.finditer(error_message, str(cm.output))
            ]
            # we check that two errors have been logged, one per product
            self.assertEqual(len(error_message_indexes_list), 2)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_search_cloudcover_onda(
        self, mock__request, mock_normalize_results
    ):
        """A query with a ODataV4Search (here onda) must only use cloudCover filtering for non-radar collections"""
        # per_product_metadata_query parameter is updated to False if it is necessary
        per_product_metadata_query = (
            self.onda_search_plugin.config.per_product_metadata_query
        )
        if per_product_metadata_query:
            self.onda_search_plugin.config.per_product_metadata_query = False

        self.onda_search_plugin.query(collection="S2_MSI_L1C", cloudCover=50)
        # restore per_product_metadata_query initial value if it is necessary
        if (
            self.onda_search_plugin.config.per_product_metadata_query
            != per_product_metadata_query
        ):
            self.onda_search_plugin.config.per_product_metadata_query = (
                per_product_metadata_query
            )

        mock__request.assert_called()
        self.assertIn(
            "cloudCoverPercentage", mock__request.call_args_list[-1][0][1].url
        )
        mock__request.reset_mock()

        self.onda_search_plugin.query(collection="S1_SAR_GRD", cloudCover=50)
        mock__request.assert_called()
        self.assertNotIn(
            "cloudCoverPercentage", mock__request.call_args_list[-1][0][1].url
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_distinct_collection_mtd_mapping(
        self, mock__request
    ):
        """The metadata mapping for ODataV4Search should not mix specific collections metadata-mapping"""
        wkt_geometry = self.search_criteria_s2_msi_l1c["geometry"].wkt
        mock__request.return_value = mock.Mock()
        result = {
            "value": [
                {
                    "id": "foo",
                    "footprint": wkt_geometry,
                },
            ],
        }
        mock__request.return_value.json.side_effect = [5, result, 5, result]
        search_plugin = self.get_search_plugin(provider="onda")

        # update metadata_mapping only for S1_SAR_GRD
        search_plugin.config.products["S1_SAR_GRD"]["metadata_mapping"]["bar"] = (
            None,
            "baz",
        )
        products = search_plugin.query(
            collection="S1_SAR_GRD",
            auth=None,
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "baz")

        # search with another collection
        self.assertNotIn(
            "bar", search_plugin.config.products["S1_SAR_SLC"]["metadata_mapping"]
        )
        products = search_plugin.query(
            collection="S1_SAR_SLC",
            auth=None,
        )
        self.assertNotIn("bar", products.data[0].properties)


class TestSearchPluginStacSearch(BaseSearchPluginTest):
    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_mapping_earthsearch(self, mock__request):
        """The metadata mapping for earth_search should return well formatted results"""  # noqa

        geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "id": "foo",
                        "geometry": geojson_geometry,
                        "properties": {
                            "s2:product_uri": "S2B_MSIL1C_20201009T012345_N0209_R008_T31TCJ_20201009T123456.SAFE",
                        },
                    },
                    {
                        "id": "bar",
                        "geometry": geojson_geometry,
                        "properties": {
                            "s2:product_uri": "S2B_MSIL1C_20200910T012345_N0209_R008_T31TCJ_20200910T123456.SAFE",
                        },
                    },
                    {
                        "id": "bar",
                        "geometry": geojson_geometry,
                        "properties": {
                            "s2:product_uri": "S2B_MSIL1C_20201010T012345_N0209_R008_T31TCJ_20201010T123456.SAFE",
                        },
                    },
                ],
            },
        ]

        search_plugin = self.get_search_plugin(self.collection, "earth_search")

        products = search_plugin.query(
            prep=PreparedSearch(page=1, limit=2),
            **self.search_criteria_s2_msi_l1c,
        )
        self.assertEqual(
            products[0].properties["eodag:product_path"],
            "products/2020/10/9/S2B_MSIL1C_20201009T012345_N0209_R008_T31TCJ_20201009T123456",
        )
        self.assertEqual(
            products[1].properties["eodag:product_path"],
            "products/2020/9/10/S2B_MSIL1C_20200910T012345_N0209_R008_T31TCJ_20200910T123456",
        )
        self.assertEqual(
            products[2].properties["eodag:product_path"],
            "products/2020/10/10/S2B_MSIL1C_20201010T012345_N0209_R008_T31TCJ_20201010T123456",
        )

    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_default_geometry(self, mock__request):
        """The metadata mapping for a stac provider should return a default geometry"""

        geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "id": "foo",
                        "geometry": geojson_geometry,
                    },
                    {
                        "id": "bar",
                        "geometry": None,
                    },
                ],
            },
        ]

        search_plugin = self.get_search_plugin(self.collection, "earth_search")

        products = search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=3,
            )
        )
        self.assertEqual(
            products.data[0].geometry, self.search_criteria_s2_msi_l1c["geometry"]
        )
        self.assertEqual(products[1].geometry.bounds, (-180.0, -90.0, 180.0, 90.0))

    @mock.patch("eodag.plugins.search.geodes.GeodesSearch._request", autospec=True)
    @mock.patch(
        "eodag.api.product.drivers.base.DatasetDriver.guess_asset_key_and_roles",
        autospec=True,
    )
    @mock.patch.dict(QueryStringSearch.extract_properties, {"json": mock.MagicMock()})
    def test_plugins_search_stacsearch_normalize_asset_key_from_href(
        self, mock_guess_asset_key_and_roles, mock_geodes_search_request
    ):
        """normalize_results must guess asset key from href if asset_key_from_href is set to True"""

        mock_properties_from_json = QueryStringSearch.extract_properties["json"]
        mock_properties_from_json.return_value = {
            "geometry": "POINT (0 0)",
            "assets": {
                "foo": {
                    "href": "https://example.com/foo",
                    "roles": ["bar"],
                },
            },
        }
        mock_guess_asset_key_and_roles.return_value = ("normalized_key", ["some_role"])

        # guess asset key from href
        search_plugin = self.get_search_plugin(self.collection, "earth_search")
        self.assertFalse(hasattr(search_plugin.config, "asset_key_from_href"))
        products = search_plugin.normalize_results([{}])
        mock_guess_asset_key_and_roles.assert_called_once_with(
            products[0].driver, "https://example.com/foo", products[0]
        )
        self.assertEqual(len(products[0].assets), 1)
        self.assertEqual(products[0].assets["normalized_key"]["roles"], ["some_role"])

        mock_guess_asset_key_and_roles.reset_mock()
        # guess asset key from origin key
        search_plugin = self.get_search_plugin(self.collection, "geodes")
        self.assertEqual(search_plugin.config.asset_key_from_href, False)
        products = search_plugin.normalize_results([{}])
        mock_guess_asset_key_and_roles.assert_called_once_with(
            products[0].driver, "foo", products[0]
        )
        self.assertEqual(len(products[0].assets), 1)
        self.assertEqual(products[0].assets["normalized_key"]["roles"], ["some_role"])
        # title is also set using normlized key
        self.assertEqual(
            products[0].assets["normalized_key"]["title"], "normalized_key"
        )

        mock_guess_asset_key_and_roles.reset_mock()
        # no-extension href: driver returns (None, None), original key and roles are kept
        mock_guess_asset_key_and_roles.return_value = (None, None)
        mock_properties_from_json.return_value = {
            "geometry": "POINT (0 0)",
            "assets": {
                "some-asset": {
                    "href": "https://example.com/foo",
                    "roles": ["bar"],
                },
            },
        }
        search_plugin = self.get_search_plugin(self.collection, "earth_search")
        products = search_plugin.normalize_results([{}])
        self.assertEqual(len(products[0].assets), 1)
        self.assertIn("some-asset", products[0].assets)
        self.assertEqual(products[0].assets["some-asset"]["roles"], ["bar"])
        self.assertEqual(products[0].assets["some-asset"]["title"], "some-asset")

        mock_guess_asset_key_and_roles.reset_mock()
        # no-extension href with existing title: original title is preserved
        mock_guess_asset_key_and_roles.return_value = (None, None)
        mock_properties_from_json.return_value = {
            "geometry": "POINT (0 0)",
            "assets": {
                "some-asset": {
                    "href": "https://example.com/foo",
                    "roles": ["bar"],
                    "title": "My custom title",
                },
            },
        }
        search_plugin = self.get_search_plugin(self.collection, "earth_search")
        products = search_plugin.normalize_results([{}])
        self.assertEqual(len(products[0].assets), 1)
        self.assertIn("some-asset", products[0].assets)
        self.assertEqual(products[0].assets["some-asset"]["title"], "My custom title")

    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    def test_plugins_search_stacsearch_opened_time_intervals(self, mock_requests_post):
        """Opened time intervals must be handled by StacSearch plugin"""
        mock_requests_post.return_value = mock.Mock()
        mock_requests_post.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "id": "foo",
                        "geometry": None,
                    },
                ],
            },
        ] * 4
        search_plugin = self.get_search_plugin(self.collection, "earth_search")

        search_plugin.query(
            start_datetime="2020-01-01",
            end_datetime="2020-01-02",
        )
        self.assertEqual(
            mock_requests_post.call_args.kwargs["json"]["datetime"],
            "2020-01-01T00:00:00.000Z/2020-01-02T00:00:00.000Z",
        )

        search_plugin.query(start_datetime="2020-01-01")
        self.assertEqual(
            mock_requests_post.call_args.kwargs["json"]["datetime"],
            "2020-01-01T00:00:00.000Z/..",
        )

        search_plugin.query(end_datetime="2020-01-02")
        self.assertEqual(
            mock_requests_post.call_args.kwargs["json"]["datetime"],
            "../2020-01-02T00:00:00.000Z",
        )

        search_plugin.query()
        self.assertNotIn("datetime", mock_requests_post.call_args.kwargs["json"])

    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_distinct_collection_mtd_mapping(
        self, mock__request
    ):
        """The metadata mapping for a stac provider should not mix specific collections metadata-mapping"""
        mock__request.return_value = mock.Mock()
        result = {
            "features": [
                {
                    "id": "foo",
                    "geometry": None,
                },
            ],
        }
        mock__request.return_value.json.side_effect = [result, result]
        search_plugin = self.get_search_plugin(self.collection, "earth_search")

        # update metadata_mapping only for S2_MSI_L1C
        search_plugin.config.products["S2_MSI_L1C"]["metadata_mapping"]["bar"] = (
            None,
            "baz",
        )
        products = search_plugin.query(
            collection="S2_MSI_L1C",
            auth=None,
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "baz")

        # search with another collection
        self.assertNotIn(
            "metadata_mapping", search_plugin.config.products["S1_SAR_GRD"]
        )
        products = search_plugin.query(
            collection="S1_SAR_GRD",
            auth=None,
        )
        self.assertNotIn("bar", products.data[0].properties)

    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_distinct_collection_mtd_mapping_earth_search(
        self, mock__request
    ):
        """The metadata mapping for a earth_search should correctly build tileIdentifier"""
        mock__request.return_value = mock.Mock()
        result = {
            "features": [
                {
                    "id": "foo",
                    "geometry": None,
                    "properties": {
                        "mgrs:utm_zone": "31",
                        "mgrs:latitude_band": "T",
                        "mgrs:grid_square": "CJ",
                    },
                },
            ],
        }
        collection = "S2_MSI_L1C"
        mock__request.return_value.json.side_effect = [result]
        search_plugin = self.get_search_plugin(collection, "earth_search")

        products = search_plugin.query(
            collection=collection,
            auth=None,
        )
        self.assertIn("grid:code", products.data[0].properties)
        self.assertEqual(
            products.data[0].properties["grid:code"],
            "MGRS-31TCJ",
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_stacsearch_discover_queryables(self, mock_request):
        provider_queryables = {
            "type": "object",
            "title": "Querable",
            "properties": {
                "dataset_id": {
                    "title": "dataset_id",
                    "type": "string",
                    "oneOf": [
                        {
                            "const": "EO:ESA:DAT:COP-DEM",
                            "title": "EO:ESA:DAT:COP-DEM",
                            "group": None,
                        }
                    ],
                },
                "bbox": {
                    "title": "Bbox",
                    "type": "array",
                    "minItems": 4,
                    "maxItems": 4,
                    "items": [
                        {"type": "number", "maximum": 180, "minimum": -180},
                        {"type": "number", "maximum": 90, "minimum": -90},
                        {"type": "number", "maximum": 180, "minimum": -180},
                        {"type": "number", "maximum": 90, "minimum": -90},
                    ],
                },
                "productIdentifier": {
                    "title": "Product Identifier",
                    "type": "string",
                    "pattern": "^[a-zA-Z0-9]+$",
                },
                "collection": {
                    "title": "Collection",
                    "type": "string",
                    "oneOf": [
                        {"const": "DGE_30", "title": "DGE_30", "group": None},
                        {"const": "DGE_90", "title": "DGE_90", "group": None},
                        {"const": "DTE_30", "title": "DTE_30", "group": None},
                        {"const": "DTE_90", "title": "DTE_90", "group": None},
                    ],
                },
                "startdate": {
                    "title": "Start Date",
                    "type": "string",
                    "format": "date-time",
                    "minimum": "",
                    "maximum": "",
                    "default": "",
                },
                "enddate": {
                    "title": "End Date",
                    "type": "string",
                    "format": "date-time",
                    "minimum": "",
                    "maximum": "",
                    "default": "",
                },
            },
            "required": ["dataset_id"],
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="wekeo_main")
        queryables = plugin.discover_queryables(
            collection="COP_DEM_GLO90_DGED", provider="wekeo_main"
        )
        self.assertIn("collection", queryables)
        self.assertIn("geom", queryables)
        self.assertIn("start", queryables)
        self.assertIn("end", queryables)

    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_unparsed_query_parameters(self, mock__request):
        """search_param_unparsed should pass query params as-is to the provider"""
        result = {"features": []}
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [result]
        search_plugin = self.get_search_plugin(provider="earth_search")
        search_plugin.query(query={"foo": "bar", "baz": "qux"})

        self.assertTrue(mock__request.called)
        called_args, _ = mock__request.call_args
        prepared_search = called_args[1]
        self.assertEqual(
            prepared_search.query_params["query"], {"foo": "bar", "baz": "qux"}
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_dedl_discover_queryables(self, mock_request):
        """
        Test the discovery and parsing of queryables from the DEDL provider.

        This test verifies that:
        - The "ecmwf:time" field is correctly interpreted as an annotated list with a
        single literal value "00:00".
        - The "start" field is interpreted as an annotated union of datetime and date types.
        - The "geom" field is interpreted as an annotated union that includes a string,
        a dictionary with string keys and float values, and subclasses of BaseGeometry.

        The test mocks the _request method of the plugin to simulate a response with
        predefined queryables, then verifies the correctness of the resulting type annotations.
        """
        provider_queryables = {
            "$schema": "https://json-schema.org/draft/2019-09/schema",
            "type": "object",
            "title": "Queryables for EODAG STAC API",
            "description": "Queryable names for the EODAG STAC API Item Search filter. ",
            "properties": {
                "ecmwf:variable": {
                    "default": "10m_u_component_of_wind",
                    "items": {
                        "enum": [
                            "10m_u_component_of_wind",
                            "10m_v_component_of_wind",
                            "2m_dewpoint_temperature",
                            "2m_temperature",
                        ],
                        "type": "string",
                    },
                    "title": "variable",
                    "type": "array",
                },
                "ecmwf:pressure_level": {
                    "items": {},
                    "title": "pressure_level",
                    "type": "array",
                },
                "ecmwf:time": {
                    "default": "00:00",
                    "description": "Model base time as HH:MM (UTC)",
                    "items": {"const": "00:00", "type": "string"},
                    "title": "time",
                    "type": "array",
                },
                "start_datetime": {
                    "anyOf": [
                        {"format": "date-time", "type": "string"},
                        {"format": "date", "type": "string"},
                    ],
                    "default": "2015-01-01T00:00:00Z",
                    "title": "start_datetime",
                },
                "end_datetime": {
                    "anyOf": [
                        {"format": "date-time", "type": "string"},
                        {"format": "date", "type": "string"},
                    ],
                    "default": "2015-01-01T00:00:00Z",
                    "title": "end_datetime",
                },
                "geometry": {
                    "description": "Geometry",
                    "ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/geometry",
                },
                "bbox": {
                    "description": "BBox",
                    "type": "array",
                    "oneOf": [
                        {"minItems": 4, "maxItems": 4},
                        {"minItems": 6, "maxItems": 6},
                    ],
                    "items": {"type": "number"},
                },
            },
            "required": [
                "ecmwf:variable",
                "ecmwf:time",
                "start_datetime",
                "geometry",
                "bbox",
            ],
            "additionalProperties": False,
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="dedl")
        queryables_dedl = plugin.discover_queryables(
            collection="CAMS_GAC_FORECAST", provider="dedl"
        )

        # Check that "ecmwf:time" has type Annotated[list[Literal['00:00']], ...]
        self.assertIn("ecmwf_time", queryables_dedl)
        annotated_type = queryables_dedl["ecmwf_time"]
        args = get_args(annotated_type)
        base_type = args[0]
        self.assertEqual(get_origin(base_type), list)
        literal_args = get_args(base_type)
        self.assertEqual(literal_args, (Literal["00:00"],))
        self.assertIn("json_schema_required", args[1].metadata)

        # Check that "start" has type Annotated[str, ...]
        self.assertIn("start", queryables_dedl)
        annotated_type = queryables_dedl["start"]
        args = get_args(annotated_type)
        self.assertEqual(args[0], str)
        self.assertNotIn("json_schema_required", args[1].metadata)

        # Check that "geom" has type Annotated[Union[str, dict[str, float], BaseGeometry], ...]
        self.assertIn("geom", queryables_dedl)
        annotated_type = queryables_dedl["geom"]
        args = get_args(annotated_type)
        base_type = args[0]
        self.assertEqual(get_origin(base_type), Union)
        union_args = get_args(base_type)
        self.assertIn(str, union_args)
        self.assertTrue(any(get_origin(arg) is dict for arg in union_args))
        self.assertTrue(
            any(
                issubclass(arg, BaseGeometry)
                for arg in union_args
                if isinstance(arg, type)
            )
        )
        self.assertTrue(args[1].is_required())

        # Check that "bbox" has type fieldInfo with "required": "True" and json_schema_required in metadata
        self.assertIn("bbox", queryables_dedl)
        annotated_type = queryables_dedl["bbox"]
        args = get_args(annotated_type)
        self.assertTrue(args[1].is_required())
        self.assertIn("json_schema_required", args[1].metadata)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_stacsearch_discover_queryables_merge(self, mock_request):
        """discover_queryables must merge provider and eodag queryable types, aliases, and attributes"""
        from pydantic import AliasChoices

        provider_queryables = {
            "type": "object",
            "properties": {
                # no constrained type: eodag's str type expected
                "start_datetime": {
                    "title": "Start datetime",
                    "type": "string",
                    "format": "date-time",
                    "description": "provider start description",
                },
                # simple object: should get eodag's Union type (no Literal constraint)
                "geometry": {
                    "title": "Geometry",
                    "description": "provider geometry description",
                },
                # not in eodag queryables: kept as-is from provider
                "some_provider_param": {
                    "title": "Some param",
                    "type": "integer",
                    "description": "provider-only param",
                },
            },
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="dedl")
        queryables = plugin.discover_queryables(
            collection="CAMS_GAC_FORECAST", provider="dedl"
        )

        # 1. "start" should be present (mapped from "start_datetime" via alias)
        self.assertIn("start", queryables)
        start_args = get_args(queryables["start"])
        start_type, start_fi = start_args[0], start_args[1]
        # type should be eodag's str (no Literal from provider)
        self.assertEqual(start_type, str)
        # alias from eodag should be preserved (AliasChoices with "start_datetime")
        self.assertIsInstance(start_fi.alias, AliasChoices)
        self.assertIn("start_datetime", start_fi.alias.choices)
        # non-empty attributes from provider
        self.assertEqual(start_fi.description, "provider start description")
        self.assertEqual(start_fi.title, "Start datetime")

        # 2. "geom" should be present (mapped from "geometry" via alias)
        self.assertIn("geom", queryables)
        geom_args = get_args(queryables["geom"])
        geom_type, geom_fi = geom_args[0], geom_args[1]
        # type should be eodag's Union (provider has no constraints)
        self.assertEqual(get_origin(geom_type), Union)
        # alias from eodag should be preserved
        self.assertIsInstance(geom_fi.alias, AliasChoices)
        self.assertIn("geometry", geom_fi.alias.choices)
        # non-empty attributes from provider
        self.assertEqual(geom_fi.description, "provider geometry description")
        self.assertEqual(geom_fi.title, "Geometry")

        # 3. "end" should be present (added from "datetime" split)
        #    "datetime" isn't in provider_queryables, so "end" won't be auto-added
        self.assertNotIn("end", queryables)
        self.assertNotIn("datetime", queryables)

        # 4. check "some_provider_param" is kept as-is
        self.assertIn("some_provider_param", queryables)
        sp_args = get_args(queryables["some_provider_param"])
        sp_type, sp_fi = sp_args[0], sp_args[1]
        self.assertEqual(sp_type, int)
        # non-empty attributes from provider
        self.assertEqual(sp_fi.description, "provider-only param")
        self.assertEqual(sp_fi.title, "Some param")

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_stacsearch_discover_queryables_no_pattern_propagation(
        self, mock_request
    ):
        """discover_queryables must not propagate provider `pattern` or other
        constraints onto eodag queryables, which are parsed/formatted later.
        """
        provider_queryables = {
            "type": "object",
            "properties": {
                # mimics planetary-computer strict ISO UTC pattern
                "start_datetime": {
                    "title": "Start datetime",
                    "type": "string",
                    "format": "date-time",
                    "pattern": "(\\+00:00|Z)$",
                },
                "end_datetime": {
                    "title": "End datetime",
                    "type": "string",
                    "format": "date-time",
                    "pattern": "(\\+00:00|Z)$",
                },
            },
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="dedl")
        queryables = plugin.discover_queryables(
            collection="CAMS_GAC_FORECAST", provider="dedl"
        )

        # "start" / "end" must be present and carry no `pattern` metadata
        for key in ("start", "end"):
            self.assertIn(key, queryables)
            field_type, field_info = get_args(queryables[key])[:2]
            self.assertEqual(field_type, str)
            patterns = [
                getattr(m, "pattern", None)
                for m in field_info.metadata
                if getattr(m, "pattern", None)
                and getattr(m, "pattern", None) is not PydanticUndefined
            ]
            self.assertEqual(
                patterns,
                [],
                f"unexpected pattern metadata propagated to `{key}`: {patterns}",
            )

        # build the pydantic model from these queryables and validate
        model = QueryablesDict(**queryables).get_model()
        model.model_validate(
            {
                "collection": "CAMS_GAC_FORECAST",
                "start_datetime": "2024-01-01",
                "end_datetime": "2024-01-31",
            }
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_stacsearch_discover_queryables_datetime_split(
        self, mock_request
    ):
        """discover_queryables must split provider 'datetime' into 'start' and 'end'"""
        from pydantic import AliasChoices

        provider_queryables = {
            "type": "object",
            "properties": {
                "datetime": {
                    "title": "Datetime",
                    "type": "string",
                    "format": "date-time",
                },
                "some_param": {
                    "title": "Some param",
                    "type": "string",
                },
            },
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="dedl")
        queryables = plugin.discover_queryables(
            collection="CAMS_GAC_FORECAST", provider="dedl"
        )

        # "datetime" itself must not appear in queryables
        self.assertNotIn("datetime", queryables)

        # "start" and "end" must be added from eodag definitions
        self.assertIn("start", queryables)
        self.assertIn("end", queryables)

        # check "start" has the expected eodag alias
        start_args = get_args(queryables["start"])
        start_fi = start_args[1]
        self.assertIsInstance(start_fi.alias, AliasChoices)
        self.assertIn("start_datetime", start_fi.alias.choices)

        # check "end" has the expected eodag alias
        end_args = get_args(queryables["end"])
        end_fi = end_args[1]
        self.assertEqual(end_fi.alias, "end_datetime")

        # provider-only param still present
        self.assertIn("some_param", queryables)

    def test_plugins_search_stacsearch_uses_stac_defaults(self):
        """StacSearch must use STAC defaults from normalize_results."""
        from eodag.config import PluginConfig
        from eodag.plugins.search.qssearch import StacSearch

        class _NewStacSearch(StacSearch):
            pass

        config = PluginConfig()
        plugin = _NewStacSearch("some_provider", config)
        self.assertEqual(plugin.config.results_entry, "features")


class TestSearchPluginGeodesSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginGeodesSearch, self).setUp()
        self.provider = "geodes"
        self.search_plugin = self.get_search_plugin(provider=self.provider)

    def test_plugins_search_geodes_plugin_class(self):
        """The geodes provider must use the GeodesSearch plugin"""
        from eodag.plugins.search.geodes import GeodesSearch

        self.assertIsInstance(self.search_plugin, GeodesSearch)

    def _build_product(
        self, identifier, checksum, endpoint_url="https://geodes.example/data"
    ):
        download_link = f"https://geodes.example/data/{identifier}/file_{checksum}.tif"
        return EOProduct(
            self.provider,
            {
                "id": identifier,
                "geometry": "POINT (0 0)",
                "eodag:download_link": download_link,
                "geodes:endpoint_url": endpoint_url,
            },
        )

    @mock.patch("eodag.plugins.search.geodes.GeodesSearch._request", autospec=True)
    def test_plugins_search_geodes_get_availability(self, mock__request):
        """_get_availability must POST to the fastavailability endpoint with the
        download_link and endpoint_url for each product"""
        mock_response = mock.Mock()
        mock_response.json.return_value = {"products": []}
        mock__request.return_value = mock_response

        p1 = self._build_product("PROD1", "abc")
        p2 = self._build_product("PROD2", "def")

        result = self.search_plugin._get_availability([p1, p2])

        self.assertEqual(result, {"products": []})
        self.assertEqual(mock__request.call_count, 1)
        prep = mock__request.call_args.args[1]
        # url must be derived from api_endpoint (replacing api/stac/search by fastavailability)
        self.assertEqual(
            prep.url,
            self.search_plugin.config.api_endpoint.replace(
                "api/stac/search", "fastavailability"
            ),
        )
        self.assertEqual(
            prep.query_params,
            {
                "availability": [
                    {
                        "href": p1.properties["eodag:download_link"],
                        "endpointURL": "https://geodes.example/data",
                    },
                    {
                        "href": p2.properties["eodag:download_link"],
                        "endpointURL": "https://geodes.example/data",
                    },
                ]
            },
        )

    @mock.patch("eodag.plugins.search.geodes.GeodesSearch._request", autospec=True)
    def test_plugins_search_geodes_get_availability_skips_missing_fields(
        self, mock__request
    ):
        """Products without eodag:download_link or geodes:endpoint_url must be skipped
        in the availability request body"""
        mock_response = mock.Mock()
        mock_response.json.return_value = {"products": []}
        mock__request.return_value = mock_response

        p_ok = self._build_product("PROD1", "abc")
        p_no_url = EOProduct(
            self.provider,
            {
                "id": "PROD2",
                "geometry": "POINT (0 0)",
                "eodag:download_link": "https://geodes.example/data/PROD2/file.tif",
            },
        )
        p_no_link = EOProduct(
            self.provider,
            {
                "id": "PROD3",
                "geometry": "POINT (0 0)",
                "geodes:endpoint_url": "https://geodes.example/data",
            },
        )

        self.search_plugin._get_availability([p_ok, p_no_url, p_no_link])

        prep = mock__request.call_args.args[1]
        self.assertEqual(len(prep.query_params["availability"]), 1)
        self.assertEqual(
            prep.query_params["availability"][0]["href"],
            p_ok.properties["eodag:download_link"],
        )

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._get_availability", autospec=True
    )
    def test_plugins_search_geodes_set_availability_online(self, mock_get_availability):
        """_set_availability must set order:status to ONLINE when asset is available"""
        from eodag.api.product.metadata_mapping import ONLINE_STATUS

        product = self._build_product("PROD1", "abc")
        mock_get_availability.return_value = {
            "products": [
                {
                    "id": "PROD1",
                    "files": [{"checksum": "abc", "available": True}],
                }
            ]
        }

        self.search_plugin._set_availability([product])

        self.assertEqual(product.properties["order:status"], ONLINE_STATUS)

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._get_availability", autospec=True
    )
    def test_plugins_search_geodes_set_availability_offline(
        self, mock_get_availability
    ):
        """_set_availability must set order:status to OFFLINE when asset is not available"""
        from eodag.api.product.metadata_mapping import OFFLINE_STATUS

        product = self._build_product("PROD1", "abc")
        mock_get_availability.return_value = {
            "products": [
                {
                    "id": "PROD1",
                    "files": [{"checksum": "abc", "available": False}],
                }
            ]
        }

        self.search_plugin._set_availability([product])

        self.assertEqual(product.properties["order:status"], OFFLINE_STATUS)

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._get_availability", autospec=True
    )
    def test_plugins_search_geodes_set_availability_no_match(
        self, mock_get_availability
    ):
        """When no matching product/asset is found in the availability response,
        order:status must be left unchanged and a warning must be logged"""
        product = self._build_product("PROD1", "abc")
        product.properties["order:status"] = "untouched"
        # no matching id in response
        mock_get_availability.return_value = {"products": []}

        with self.assertLogs("eodag.search.geodes", level="WARNING") as log_ctx:
            self.search_plugin._set_availability([product])

        self.assertEqual(product.properties["order:status"], "untouched")
        self.assertTrue(
            any("Could not update availability" in m for m in log_ctx.output)
        )

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._get_availability", autospec=True
    )
    def test_plugins_search_geodes_set_availability_ambiguous_asset(
        self, mock_get_availability
    ):
        """Products whose checksum matches more than one file must be skipped"""
        product = self._build_product("PROD1", "abc")
        product.properties["order:status"] = "untouched"
        mock_get_availability.return_value = {
            "products": [
                {
                    "id": "PROD1",
                    "files": [
                        {"checksum": "abc", "available": True},
                        {"checksum": "abc", "available": False},
                    ],
                }
            ]
        }

        with self.assertLogs("eodag.search.geodes", level="WARNING"):
            self.search_plugin._set_availability([product])

        self.assertEqual(product.properties["order:status"], "untouched")

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._set_availability", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.StacSearch.normalize_results", autospec=True
    )
    def test_plugins_search_geodes_normalize_results_calls_set_availability(
        self, mock_super_normalize, mock_set_availability
    ):
        """normalize_results must delegate to StacSearch.normalize_results and
        then call _set_availability with the resulting products"""
        product = self._build_product("PROD1", "abc")
        mock_super_normalize.return_value = [product]

        results = self.search_plugin.normalize_results([{}])

        self.assertEqual(results, [product])
        mock_set_availability.assert_called_once_with(self.search_plugin, [product])

    @responses.activate
    def test_plugins_search_geodes_normalize(self):
        """normalize_results must shorten asset keys and parse the asset
        ``description`` field into structured ``geodes:*`` / ``file:*`` properties."""

        # Mock availability (content irrelevant for this test)
        responses.add(
            responses.POST,
            "https://geodes-portal.cnes.fr/fastavailability",
            json={"products": []},
            status=200,
        )

        with open(self.provider_resp_dir / "geodes_search.json", encoding="utf-8") as f:
            raw_features = json.load(f)["features"]
        raw_assets = raw_features[0]["assets"]
        quicklook_asset = raw_assets[
            "2025/04/02/S2A/S2A_MSIL1C_20250402T175741_N0511_R141_T14ULD_20250403T022035_quicklook.jpg"
        ]
        zip_asset = raw_assets[
            "S2A_MSIL1C_20250402T175741_N0511_R141_T14ULD_20250403T022035.zip"
        ]

        search_plugin = self.get_search_plugin(provider="geodes")
        normalized = search_plugin.normalize_results(RawSearchResult(raw_features))

        self.assertEqual(len(normalized), 1)
        self.assertDictEqual(
            dict(normalized[0].assets),
            {
                "quicklook.jpg": {
                    "href": quicklook_asset["href"],
                    "title": "quicklook.jpg",
                    "description": quicklook_asset["description"],
                    "type": "image/jpeg",
                    "roles": ["overview"],
                    "file:size": 18684,
                    "geodes:reference": False,
                    "geodes:online": True,
                    "geodes:datatype": "QUICKLOOK_SD",
                    "file:checksum": "1003ae6b1edf05adf7c46cb759ffeaec",
                },
                "zip": {
                    "href": zip_asset["href"],
                    "title": "zip",
                    "description": zip_asset["description"],
                    "type": "application/zip",
                    "roles": ["auxiliary"],
                    "file:size": 764210185,
                    "geodes:reference": False,
                    "geodes:online": False,
                    "geodes:datatype": "RAWDATA",
                    "file:checksum": "86f828c4c7e921615d1cd0476604f780",
                },
            },
        )


class TestSearchPluginMeteoblueSearch(BaseSearchPluginTest):
    @mock.patch("eodag.plugins.authentication.qsauth.requests.get", autospec=True)
    def setUp(self, mock_requests_get):
        super(TestSearchPluginMeteoblueSearch, self).setUp()
        # enable long diffs in test reports
        self.maxDiff = None
        # One of the providers that has a MeteoblueSearch Search plugin
        provider = "meteoblue"
        self.search_plugin = self.get_search_plugin(provider=provider)
        self.auth_plugin = self.get_auth_plugin(self.search_plugin)
        self.auth_plugin.config.credentials = {"cred": "entials"}
        self.auth = self.auth_plugin.authenticate()

    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    def test_plugins_search_buildpostsearchresult_count_and_search(
        self, mock_requests_post
    ):
        """A query with a MeteoblueSearch must return a single result"""

        # custom query for meteoblue
        custom_query = {"queries": {"foo": "bar"}}
        collection_config = {"platform": "NEMSGLOBAL", "alias": "THE.ALIAS"}
        setattr(self.search_plugin.config, "collection_config", collection_config)
        products = self.search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.auth_plugin, auth=self.auth),
            **custom_query,
        )

        mock_requests_post.assert_called_with(
            self.search_plugin.config.api_endpoint,
            json=mock.ANY,
            headers=USER_AGENT,
            timeout=DEFAULT_SEARCH_TIMEOUT,
            auth=self.auth,
            verify=True,
        )
        self.assertEqual(products.number_matched, 1)
        self.assertIsInstance(products.data[0], EOProduct)
        endpoint = "https://my.meteoblue.com/dataset/query"
        default_geom = {
            "coordinates": [
                [[180, -90], [180, 90], [-180, 90], [-180, -90], [180, -90]]
            ],
            "type": "Polygon",
        }
        # check eodag:download_link
        self.assertEqual(
            products.data[0].properties["eodag:download_link"],
            f"{endpoint}?" + json.dumps({"geometry": default_geom, **custom_query}),
        )
        # check eodag:order_link
        self.assertEqual(
            products.data[0].properties["eodag:order_link"],
            f"{endpoint}?"
            + json.dumps(
                {
                    "geometry": default_geom,
                    "runOnJobQueue": True,
                    **custom_query,
                }
            ),
        )


class MockResponse:
    def __init__(self, json_data, status_code):
        self.json_data = json_data
        self.status_code = status_code

    def json(self):
        return self.json_data

    def raise_for_status(self):
        if self.status_code != 200:
            raise RequestError


class TestSearchPluginCreodiasS3Search(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginCreodiasS3Search, self).setUp()
        self.provider = "creodias_s3"

    @mock.patch(
        "eodag.plugins.authentication.aws_auth.AwsAuth.get_s3_client", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_creodias_s3_links(self, mock_request, mock_s3_client):
        # s3 links should be added to products with register_downloader
        search_plugin = self.get_search_plugin("S1_SAR_GRD", self.provider)
        client = boto3.client("s3", aws_access_key_id="a", aws_secret_access_key="b")
        mock_s3_client.return_value = client
        stubber = Stubber(client)
        s3_response_file = (
            Path(TEST_RESOURCES_PATH) / "provider_responses/creodias_s3_objects.json"
        )
        with open(s3_response_file) as f:
            list_objects_response = json.load(f)
        creodias_search_result_file = (
            Path(TEST_RESOURCES_PATH) / "eodag_search_result_creodias.json"
        )
        with open(creodias_search_result_file) as f:
            creodias_search_result = json.load(f)
        mock_request.return_value = MockResponse(creodias_search_result, 200)

        res = search_plugin.query(collection="S1_SAR_GRD")
        for product in res.data:
            download_plugin = self.plugins_manager.get_download_plugin(product)
            auth_plugin = self.plugins_manager.get_auth_plugin(download_plugin, product)
            stubber.add_response("list_objects", list_objects_response)
            stubber.activate()
            # fails if credentials are missing
            auth_plugin.config.credentials = {
                "aws_access_key_id": "",
                "aws_secret_access_key": "",
            }
            with self.assertRaisesRegex(
                MisconfiguredError,
                r"^Incomplete credentials .* \['aws_access_key_id', 'aws_secret_access_key'\]$",
            ):
                product.register_downloader(download_plugin, auth_plugin)
            auth_plugin.config.credentials = {
                "aws_access_key_id": "foo",
                "aws_secret_access_key": "bar",
            }
            product.register_downloader(download_plugin, auth_plugin)
        assets = res.data[0].assets
        self.assertEqual(3, len(assets))
        # check if s3 links have been created correctly
        for asset in assets.values():
            self.assertIn("s3://eodata/Sentinel-1/SAR/GRD/2014/10/10", asset["href"])

        # no occur should occur and assets should be empty if list_objects does not have content
        # (this situation will occur if the product does not have assets but is a tar file)
        stubber.add_response("list_objects", {})
        download_plugin = self.plugins_manager.get_download_plugin(res.data[0])
        auth_plugin = self.plugins_manager.get_auth_plugin(download_plugin, res.data[0])
        res.data[0].driver = None
        res.data[0].assets = AssetsDict(res.data[0])
        res.data[0].register_downloader(download_plugin, auth_plugin)
        self.assertIsNotNone(res.data[0].driver)
        self.assertEqual(0, len(res.data[0].assets))

    @mock.patch(
        "eodag.plugins.authentication.aws_auth.AwsAuth.get_s3_client", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_creodias_s3_client_error(
        self, mock_request, mock_s3_client
    ):
        # request error should be raised when there is an error when fetching data from the s3
        search_plugin = self.get_search_plugin("S1_SAR_GRD", self.provider)
        client = boto3.client("s3", aws_access_key_id="a", aws_secret_access_key="b")
        mock_s3_client.return_value = client
        stubber = Stubber(client)

        creodias_search_result_file = (
            Path(TEST_RESOURCES_PATH) / "eodag_search_result_creodias.json"
        )
        with open(creodias_search_result_file) as f:
            creodias_search_result = json.load(f)
        mock_request.return_value = MockResponse(creodias_search_result, 200)

        with self.assertRaises(NotAvailableError):
            res = search_plugin.query(collection="S1_SAR_GRD")
            for product in res.data:
                download_plugin = self.plugins_manager.get_download_plugin(product)
                auth_plugin = self.plugins_manager.get_auth_plugin(
                    download_plugin, product
                )
                auth_plugin.config.credentials = {
                    "aws_access_key_id": "foo",
                    "aws_secret_access_key": "bar",
                }
                stubber.add_client_error("list_objects")
                stubber.activate()
                product.register_downloader(download_plugin, auth_plugin)


class TestSearchPluginECMWFSearch(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        super(TestSearchPluginECMWFSearch, cls).setUpClass()
        providers = ProvidersDict.from_configs(load_default_config())
        cls.plugins_manager = PluginManager(providers)

    def setUp(self):
        self.provider = "cop_ads"
        self.search_plugin = self.get_search_plugin(provider=self.provider)
        self.query_dates = {
            "start_datetime": "2020-01-01",
            "end_datetime": "2020-01-02",
        }
        self.collection = "CAMS_EAC4"
        self.product_dataset = "cams-global-reanalysis-eac4"
        self.collection_params = {
            "ecmwf:dataset": self.product_dataset,
        }
        self.custom_query_params = {
            "ecmwf:dataset": "cams-global-ghg-reanalysis-egg4",
            "ecmwf:step": 0,
            "ecmwf:variable": "carbon_dioxide",
            "ecmwf:pressure_level": "10",
            "ecmwf:model_level": "1",
            "ecmwf:time": "00:00",
            "ecmwf:data_format": "grib",
        }

    def get_search_plugin(self, collection=None, provider=None):
        return next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider=provider
            )
        )

    def test_plugins_search_ecmwfsearch_exclude_end_date(self):
        """ECMWFSearch.query must adapt end date in certain cases"""
        # start & stop as dates -> keep end date as it is
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01",
            end_datetime="2020-01-02",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["start_datetime"],
        )
        self.assertEqual(
            "2020-01-02T00:00:00.000Z",
            eoproduct.properties["end_datetime"],
        )
        # start & stop as datetimes, not midnight -> keep and dates as it is
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01T02:00:00Z",
            end_datetime="2020-01-02T03:00:00Z",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            "2020-01-01T02:00:00.000Z",
            eoproduct.properties["start_datetime"],
        )
        self.assertEqual(
            "2020-01-02T03:00:00.000Z",
            eoproduct.properties["end_datetime"],
        )
        # start & stop as datetimes, midnight -> exclude end date
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01T00:00:00Z",
            end_datetime="2020-01-02T00:00:00Z",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["start_datetime"],
        )
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["end_datetime"],
        )
        # start & stop same date -> keep end date
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01T00:00:00Z",
            end_datetime="2020-01-01T00:00:00Z",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["start_datetime"],
        )
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["end_datetime"],
        )

    def test_plugins_search_ecmwfsearch_dates(self):
        """ECMWFSearch.query must use default dates if missing"""

        # given start & stop
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01",
            end_datetime="2020-01-02",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            eoproduct.properties["start_datetime"],
            "2020-01-01T00:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["end_datetime"],
            "2020-01-02T00:00:00.000Z",
        )

        # missing start & stop
        results = self.search_plugin.query(
            collection=self.collection,
        )
        eoproduct = results.data[0]
        self.assertNotIn("start_datetime", eoproduct.properties)
        self.assertNotIn("end_datetime", eoproduct.properties)

    def test_plugins_search_ecmwfsearch_dont_sort_params(self):
        """ECMWFSearch.query must not sort query parameters and keep user order"""

        results = self.search_plugin.query(
            collection=self.collection,
            **self.query_dates,
            **self.custom_query_params,
            ecmwf_pressure_level=["2", "1", "3", "10"],
        )
        eoproduct = results.data[0]
        self.assertIn("qs", eoproduct.properties)
        self.assertIn("pressure_level", eoproduct.properties["qs"])
        self.assertListEqual(
            eoproduct.properties["qs"]["pressure_level"],
            ["2", "1", "3", "10"],
        )

    def test_plugins_search_ecmwfsearch_with_year_month_day_filter(self):
        """ECMWFSearch.query must use have datetime in response if year, month, day used in filters"""
        results = self.search_plugin.query(
            prep=PreparedSearch(),
            collection="ERA5_SL",
            **{
                "ecmwf:year": "2020",
                "ecmwf:month": ["02"],
                "ecmwf:day": ["20", "21"],
                "ecmwf:time": ["01:00"],
            },
        )
        eoproduct = results.data[0]

        self.assertEqual(
            eoproduct.properties["start_datetime"],
            "2020-02-20T01:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["end_datetime"],
            "2020-02-21T01:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["ecmwf:year"],
            "2020",
        )
        self.assertEqual(
            eoproduct.properties["ecmwf:month"],
            ["02"],
        )
        self.assertEqual(
            eoproduct.properties["ecmwf:day"],
            ["20", "21"],
        )
        # month with one digit
        results = self.search_plugin.query(
            prep=PreparedSearch(),
            collection="ERA5_SL",
            **{
                "ecmwf:year": "2020",
                "ecmwf:month": ["2"],
                "ecmwf:day": ["20", "21"],
                "ecmwf:time": ["01:00"],
            },
        )
        eoproduct = results.data[0]
        self.assertEqual(
            eoproduct.properties["start_datetime"],
            "2020-02-20T01:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["end_datetime"],
            "2020-02-21T01:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["ecmwf:month"],
            ["2"],
        )
        # qs (used for order request) should contain year/month/day but not date
        self.assertIn("qs", eoproduct.properties)
        self.assertIn("day", eoproduct.properties["qs"])
        self.assertIn("month", eoproduct.properties["qs"])
        self.assertIn("year", eoproduct.properties["qs"])
        self.assertNotIn("date", eoproduct.properties["qs"])

    def test_plugins_search_ecmwfsearch_collection_with_alias(self):
        """alias of collection must be used in search result"""
        self.search_plugin.config.collection_config = {
            "_collection": self.collection,
            "alias": "THE.ALIAS",
        }
        results = self.search_plugin.query(
            collection="THE.ALIAS",
            start_datetime="2020-01-01",
            end_datetime="2020-01-02",
        )
        eoproduct = results.data[0]
        self.assertEqual("THE.ALIAS", eoproduct.collection)

    def test_plugins_search_ecmwfsearch_without_collection(self):
        """
        ECMWFSearch.query must build a EOProduct from input parameters without collection.
        For test only, result cannot be downloaded.
        """
        results = self.search_plugin.query(
            PreparedSearch(count=True),
            **{
                "ecmwf:dataset": self.product_dataset,
                "start_datetime": "2020-01-01",
                "end_datetime": "2020-01-02",
            },
        )
        assert results.number_matched == 1
        eoproduct = results.data[0]
        assert eoproduct.geometry.bounds == (-180.0, -90.0, 180.0, 90.0)
        assert eoproduct.properties["start_datetime"] == "2020-01-01T00:00:00.000Z"
        assert eoproduct.properties["end_datetime"] == "2020-01-02T00:00:00.000Z"
        assert eoproduct.properties["title"] == eoproduct.properties["id"]
        assert eoproduct.properties["title"].startswith(
            f"{self.product_dataset.upper()}"
        )
        assert eoproduct.properties["eodag:order_link"].startswith("http")
        assert NOT_AVAILABLE in eoproduct.location

    def test_plugins_search_ecmwfsearch_with_collection(self):
        """ECMWFSearch.query must build a EOProduct from input parameters with predefined collection"""
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, geometry=[1, 2, 3, 4]
        )
        eoproduct = results.data[0]
        assert eoproduct.properties["title"].startswith(self.collection)
        # geom converted using to_nwse_bounds
        assert eoproduct.geometry.bounds == (1.0, 2.0, 3.0, 4.0)
        # check if collection_params is a subset of eoproduct.properties
        assert self.collection_params.items() <= eoproduct.properties.items()

        # collection default settings can be overwritten using search kwargs
        results = self.search_plugin.query(
            **self.query_dates,
            **{"collection": self.collection, "ecmwf:variable": "temperature"},
        )
        eoproduct = results.data[0]
        assert eoproduct.properties["ecmwf:variable"] == "temperature"

    def test_plugins_search_ecmwfsearch_with_area_keeps_qs(self):
        """ECMWFSearch.query with ``area`` must expose geometry in properties and keep ``area`` in qs."""
        # string format: "max_lat/min_lon/min_lat/max_lon"
        area_str = "44.0/1.0/43.0/2.0"
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, area=area_str
        )
        eoproduct = results.data[0]
        # geometry on the EOProduct is the converted polygon
        self.assertEqual(eoproduct.geometry.bounds, (1.0, 43.0, 2.0, 44.0))
        # original ``area`` is preserved in qs (used for download/order request)
        self.assertIn("qs", eoproduct.properties)
        self.assertEqual(eoproduct.properties["qs"]["area"], area_str)
        # ``area`` must not leak as a top-level property on the EOProduct
        for key in ("area", "ecmwf:area", "ecmwf_area"):
            self.assertNotIn(key, eoproduct.properties)

    def test_plugins_search_ecmwfsearch_with_area_list_not_sorted(self):
        """ECMWFSearch must not sort area parameter even if it's a list."""
        # area as list format: [North, West, South, East]
        # Use values that would be different if sorted
        area_list = [44.0, 1.0, 43.0, 2.0]
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, area=area_list
        )
        eoproduct = results.data[0]
        # geometry on the EOProduct is the converted polygon
        self.assertEqual(eoproduct.geometry.bounds, (1.0, 43.0, 2.0, 44.0))
        # original ``area`` order is preserved in qs (not sorted)
        self.assertIn("qs", eoproduct.properties)
        # area should be preserved as-is (not sorted)
        self.assertEqual(eoproduct.properties["qs"]["area"], area_list)

    def test_plugins_search_ecmwfsearch_with_feature_keeps_qs(self):
        """ECMWFSearch.query with ``feature`` must expose geometry in properties and keep ``feature`` in qs."""
        feature = {
            "shape": [
                [43.0, 1.0],
                [44.0, 1.0],
                [44.0, 2.0],
                [43.0, 2.0],
                [43.0, 1.0],
            ],
            "type": "polygon",
        }
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, feature=feature
        )
        eoproduct = results.data[0]
        self.assertEqual(eoproduct.geometry.bounds, (1.0, 43.0, 2.0, 44.0))
        self.assertIn("qs", eoproduct.properties)
        self.assertEqual(eoproduct.properties["qs"]["feature"], feature)
        for key in ("feature", "ecmwf:feature", "ecmwf_feature"):
            self.assertNotIn(key, eoproduct.properties)

    def test_plugins_search_ecmwfsearch_with_location_keeps_qs(self):
        """ECMWFSearch.query with ``location`` must expose geometry in properties and keep ``location`` in qs."""
        location = {"latitude": 43.5, "longitude": 1.5}
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, location=location
        )
        eoproduct = results.data[0]
        # single location -> point-like polygon (bbox of a point)
        self.assertEqual(eoproduct.geometry.bounds, (1.5, 43.5, 1.5, 43.5))
        self.assertIn("qs", eoproduct.properties)
        self.assertEqual(eoproduct.properties["qs"]["location"], location)
        for key in ("location", "ecmwf:location", "ecmwf_location"):
            self.assertNotIn(key, eoproduct.properties)

    def test_plugins_search_ecmwfsearch_with_custom_collection(self):
        """ECMWFSearch.query must build a EOProduct from input parameters with custom collection"""
        results = self.search_plugin.query(
            **self.query_dates,
            **self.custom_query_params,
        )
        eoproduct = results.data[0]
        assert eoproduct.properties["title"].startswith(
            self.custom_query_params["ecmwf:dataset"].upper()
        )
        # check if custom_query_params is a subset of eoproduct.properties
        for param in self.custom_query_params:
            try:
                # for numeric values
                assert eoproduct.properties[param] == ast.literal_eval(
                    self.custom_query_params[param]
                )
            except Exception:
                assert eoproduct.properties[param] == self.custom_query_params[param]

    def test_plugins_search_ecmwf_temporal_to_eodag(self):
        """ecmwf_temporal_to_eodag must parse all expected dates formats"""
        self.assertEqual(
            ecmwf_temporal_to_eodag(
                dict(day="15", month="02", year="2022", time="0600")
            ),
            ("2022-02-15T06:00:00.000Z", "2022-02-15T06:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(hday="15", hmonth="02", hyear="2022")),
            ("2022-02-15T00:00:00.000Z", "2022-02-15T00:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(date="2022-02-15")),
            ("2022-02-15T00:00:00.000Z", "2022-02-15T00:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(
                dict(date="2022-02-15T00:00:00Z/2022-02-16T00:00:00Z")
            ),
            ("2022-02-15T00:00:00.000Z", "2022-02-16T00:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(date="20220215/to/20220216")),
            ("2022-02-15T00:00:00.000Z", "2022-02-16T00:00:00.000Z"),
        )
        # List of dates
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(date=["20220215", "20220216"])),
            ("2022-02-15T00:00:00.000Z", "2022-02-16T00:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(date=["20220215"])),
            ("2022-02-15T00:00:00.000Z", "2022-02-15T00:00:00.000Z"),
        )

    def test_plugins_search_ecmwfsearch_get_available_values_from_contraints(self):
        """ECMWFSearch must return available values from constraints"""
        constraints = [
            {"date": ["2025-01-01/2025-06-01"], "variable": ["a", "b"]},
            {"date": ["2024-01-01/2024-12-01"], "variable": ["a", "b", "c"]},
        ]
        form = [
            {"name": "date", "required": True},
            {"name": "variable", "required": True},
        ]

        # with a date range as a string
        input_keywords = {"date": "2025-01-01/2025-02-01", "variable": "a"}
        available_values = self.search_plugin.available_values_from_constraints(
            constraints, input_keywords, form
        )
        available_values = {k: sorted(v) for k, v in available_values.items()}
        self.assertIn("variable", available_values)
        self.assertListEqual(["a", "b"], available_values["variable"])
        self.assertIn("date", available_values)

        # with a date range as the first element of a string list
        input_keywords = {"date": ["2025-01-01/2025-02-01"], "variable": "a"}
        available_values = self.search_plugin.available_values_from_constraints(
            constraints, input_keywords, form
        )
        available_values = {k: sorted(v) for k, v in available_values.items()}
        self.assertIn("variable", available_values)
        self.assertListEqual(["a", "b"], available_values["variable"])
        self.assertIn("date", available_values)

    def test_plugins_search_ecmwfsearch_get_available_values_from_contraints_with_keyword_required_by_form(
        self,
    ):
        """An input keyword cannot be used if it's required by form and no constraint with the given
        combination of parameters exists"""
        constraints = [
            {"date": ["2025-01-01/2025-06-01"], "variable": ["a", "b"]},
            {"date": ["2024-01-01/2024-12-01"]},
        ]
        form = [
            {"name": "date", "required": True},
            {"name": "variable", "required": True},
        ]

        # "variable" is *required* by form and *some* constraints with the given "date" defines values
        # for "variable": "variable" must be used as input keyword
        # case 1: the input value "a" is available
        input_keywords = {"date": "2025-01-01/2025-01-01", "variable": "a"}
        available_values = self.search_plugin.available_values_from_constraints(
            constraints, input_keywords, form
        )
        available_values = {k: sorted(v) for k, v in available_values.items()}
        self.assertIn("variable", available_values)
        self.assertListEqual(["a", "b"], available_values["variable"])
        self.assertIn("date", available_values)
        # case 2: the input value "c" is not available
        input_keywords = {"date": "2025-01-01/2025-01-01", "variable": "c"}
        with self.assertRaises(ValidationError) as ex:
            self.search_plugin.available_values_from_constraints(
                constraints, input_keywords, form
            )
        self.assertIn(
            "ecmwf:variable=c is not available. Allowed values are ",
            ex.exception.message,
        )

        # "variable" is *required* by form and *no* constraint with the given "date" defines values
        # for "variable": "variable" cannot be used as input keyword with this value of "date".
        input_keywords = {"date": "2024-01-01/2024-01-01", "variable": "a"}
        with self.assertRaises(ValidationError) as ex:
            self.search_plugin.available_values_from_constraints(
                constraints, input_keywords, form
            )
        self.assertEqual(
            "ecmwf:variable=a is not available. ecmwf:variable cannot be used with this combination of parameters.",
            ex.exception.message,
        )

        # "variable" is *required* by form and *no* constraint with the given "date" defines values
        # for "variable": "variable" is not used as input keyword and no available value for "variable" is returned
        input_keywords = {"date": "2024-01-01/2024-01-01"}
        available_values = self.search_plugin.available_values_from_constraints(
            constraints, input_keywords, form
        )
        available_values = {k: sorted(v) for k, v in available_values.items()}
        self.assertIn("variable", available_values)
        self.assertListEqual([], available_values["variable"])
        self.assertIn("date", available_values)

    def test_plugins_search_ecmwfsearch_get_available_values_from_contraints_with_keyword_not_required_by_form(
        self,
    ):
        """Any value is accepted if a keyword is not required by form and its list of allowed values is empty"""
        constraints = [
            {"date": ["2025-01-01/2025-06-01"], "variable": ["a", "b"]},
            {"date": ["2024-01-01/2024-12-01"]},
        ]
        form = [
            {"name": "date", "required": True},
            {"name": "variable", "required": False},
        ]

        # "variable" is *not* required by form and after applying the filter there is *no* constraint that defines
        # any values for "variable": any value for "variable" can be used as input keyword.
        input_keywords = {"date": "2024-01-01/2024-01-01", "variable": "c"}
        available_values = self.search_plugin.available_values_from_constraints(
            constraints, input_keywords, form
        )
        available_values = {k: sorted(v) for k, v in available_values.items()}
        self.assertIn("variable", available_values)
        self.assertListEqual([], available_values["variable"])
        self.assertIn("date", available_values)

        # "variable" is *not* required by form and after applying the filter there are *some* constraints that
        # define  values for "variable": "variable" must match the available values.
        # case 1: the value "a" is available
        input_keywords = {"date": "2025-01-01/2025-01-01", "variable": "a"}
        available_values = self.search_plugin.available_values_from_constraints(
            constraints, input_keywords, form
        )
        available_values = {k: sorted(v) for k, v in available_values.items()}
        self.assertIn("variable", available_values)
        self.assertListEqual(["a", "b"], available_values["variable"])
        self.assertIn("date", available_values)
        # case 2: the value "c" is not available
        input_keywords = {"date": "2025-01-01/2025-01-01", "variable": "c"}
        with self.assertRaises(ValidationError) as ex:
            self.search_plugin.available_values_from_constraints(
                constraints, input_keywords, form
            )
        self.assertIn(
            "ecmwf:variable=c is not available. Allowed values are ",
            ex.exception.message,
        )

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_ok(self, mock__fetch_data):
        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        constraints[0]["variable"].append("nitrogen_dioxide")
        constraints[0]["type"].append("validated_reanalysis")
        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        mock__fetch_data.side_effect = [constraints, form]
        collection_config = {
            "extent": {
                "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]},
                "temporal": {"interval": [["2001-01-01T00:00:00Z", None]]},
            }
        }
        setattr(self.search_plugin.config, "collection_config", collection_config)

        provider_queryables_from_constraints_file = [
            "ecmwf_year",
            "ecmwf_month",
            "ecmwf_day",
            "ecmwf_time",
            "ecmwf_variable",
            "ecmwf_leadtime_hour",
            "ecmwf_type",
            "ecmwf_product_type",
        ]
        default_values = deepcopy(
            getattr(self.search_plugin.config, "products", {}).get(
                "CAMS_EU_AIR_QUALITY_RE", {}
            )
        )
        default_values.pop("metadata_mapping", None)
        # ECMWF-like providers don't have default values anymore: override a default value
        default_values["data_format"] = "grib"
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"
        # set a parameter among the required ones of the form file with a default value in this form but not among the
        # ones of the constraints file to an empty value to check if its associated queryable has no default value
        eodag_formatted_data_format = "ecmwf:data_format"
        provider_data_format = eodag_formatted_data_format.replace("ecmwf:", "")
        self.assertIn(provider_data_format, default_values)
        self.assertIn(provider_data_format, [param["name"] for param in form])
        data_format_in_form = [
            param for param in form if param["name"] == provider_data_format
        ][0]
        self.assertTrue(data_format_in_form.get("required", False))
        self.assertIsNotNone(data_format_in_form.get("details", {}).get("default"))
        for constraint in constraints:
            self.assertNotIn(provider_data_format, constraint)
        params[eodag_formatted_data_format] = ""

        # use a parameter among the ones of the form file but not among the ones of the constraints file
        # and of provider default configuration to check if an error is raised, which is supposed to not happen
        eodag_formatted_download_format = "ecmwf:download_format"
        provider_download_format = eodag_formatted_download_format.replace("ecmwf:", "")
        self.assertNotIn(eodag_formatted_download_format, default_values)
        self.assertIn(provider_download_format, [param["name"] for param in form])
        for constraint in constraints:
            self.assertNotIn(provider_data_format, constraint)
        params[eodag_formatted_download_format] = "foo"
        # create parameters matching the first constraint
        params["variable"] = "nitrogen_dioxide"

        queryables = self.search_plugin.discover_queryables(**params)
        # no error was raised, as expected
        self.assertIsNotNone(queryables)

        mock__fetch_data.assert_has_calls(
            [
                call(
                    mock.ANY,
                    "https://ads.atmosphere.copernicus.eu/api/catalogue/v1/collections/"
                    "cams-europe-air-quality-reanalyses/constraints.json",
                ),
                call(
                    mock.ANY,
                    "https://ads.atmosphere.copernicus.eu/api/catalogue/v1/collections/"
                    "cams-europe-air-quality-reanalyses/form.json",
                ),
            ]
        )

        # queryables from provider constraints file are added (here the ones of CAMS_EU_AIR_QUALITY_RE for cop_ads)
        for provider_queryable in provider_queryables_from_constraints_file:
            provider_queryable = (
                get_queryable_from_provider(
                    provider_queryable,
                    self.search_plugin.get_metadata_mapping("CAMS_EU_AIR_QUALITY_RE"),
                )
                or provider_queryable
            )
            self.assertIn(provider_queryable, queryables)

        # default properties in provider config are added and must be default values of the queryables
        for property, default_value in self.search_plugin.config.products[
            "CAMS_EU_AIR_QUALITY_RE"
        ].items():
            queryable = queryables.get(property)
            # a special case for eodag_formatted_data_format queryable is required
            # as its default value has been overwritten by an empty value
            if queryable is not None and property == eodag_formatted_data_format:
                self.assertEqual(
                    PydanticUndefined, queryable.__metadata__[0].get_default()
                )
                # queryables with empty default values are required
                self.assertTrue(queryable.__metadata__[0].is_required())
            elif queryable is not None:
                self.assertEqual(default_value, queryable.__metadata__[0].get_default())
                # queryables with default values are not required
                self.assertFalse(queryable.__metadata__[0].is_required())

        # required queryable
        queryable = queryables.get("ecmwf:month")
        if queryable is not None:
            self.assertEqual(["01"], queryable.__metadata__[0].get_default())
            self.assertFalse(queryable.__metadata__[0].is_required())

        # check that queryable constraints from the constraints file are in queryable info
        queryable = queryables.get("ecmwf:variable")
        if queryable is not None:
            variable_constraints = constraints[0]["variable"]
            # remove queryable constraints duplicates to make the assertion works
            self.assertSetEqual(
                set(variable_constraints),
                set(get_args(queryable.__origin__.__args__[0])),
            )

        # reset mock
        mock__fetch_data.reset_mock()
        mock__fetch_data.side_effect = [constraints, form]
        # with additional param
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"
        params["ecmwf:variable"] = "a"
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIsNotNone(queryables)

        # cached values are not used to make the set of unit tests work then the mock is called again
        mock__fetch_data.assert_has_calls(
            [
                call(
                    mock.ANY,
                    "https://ads.atmosphere.copernicus.eu/api/catalogue/v1/collections/"
                    "cams-europe-air-quality-reanalyses/constraints.json",
                ),
                call(
                    mock.ANY,
                    "https://ads.atmosphere.copernicus.eu/api/catalogue/v1/collections/"
                    "cams-europe-air-quality-reanalyses/form.json",
                ),
            ]
        )

        self.assertEqual(12, len(queryables))
        # default properties called in function arguments are added and must be default values of the queryables
        queryable = queryables.get("ecmwf:variable")
        if queryable is not None:
            self.assertEqual("a", queryable.__metadata__[0].get_default())
            self.assertFalse(queryable.__metadata__[0].is_required())

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_ko(self, mock__fetch_data):
        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        mock__fetch_data.side_effect = [constraints, form]

        default_values = deepcopy(
            getattr(self.search_plugin.config, "products", {}).get(
                "CAMS_EU_AIR_QUALITY_RE", {}
            )
        )
        default_values.pop("metadata_mapping", None)
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"

        # use a wrong parameter, e.g. it is not among the ones of the form file, not among
        # the ones of the constraints file and not among the ones of default provider configuration
        wrong_queryable = "foo"
        self.assertNotIn(wrong_queryable, default_values)
        self.assertNotIn(wrong_queryable, [param["name"] for param in form])
        for constraint in constraints:
            self.assertNotIn(wrong_queryable, constraint)
        params[wrong_queryable] = "bar"

        # Test the function, expecting ValidationError to be raised
        with self.assertRaises(ValidationError) as context:
            self.search_plugin.discover_queryables(**params)
        self.assertEqual(
            f"'{wrong_queryable}' is not a queryable parameter for {self.provider}",
            context.exception.message,
        )

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_allow_discovered_metadata(
        self, mock__fetch_data
    ):
        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        mock__fetch_data.side_effect = [constraints, form]

        provider_search_plugin_config = copy_deepcopy(self.search_plugin.config)
        self.search_plugin.config.discover_metadata = {
            "auto_discovery": True,
            "search_param": "{metadata}",
            "metadata_pattern": "^(feature|interpolation|grid)$",
        }

        default_values = deepcopy(
            getattr(self.search_plugin.config, "products", {}).get(
                "CAMS_EU_AIR_QUALITY_RE", {}
            )
        )
        default_values.pop("metadata_mapping", None)
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"
        params["interpolation"] = "nearest"

        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIsNotNone(queryables)

        # restore the original config
        self.search_plugin.config = provider_search_plugin_config

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_default_by_form(
        self, mock__fetch_data
    ):
        """A queryable with a default value must use that default value and being not required."""
        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        mock__fetch_data.side_effect = [constraints, form]

        default_values = deepcopy(
            getattr(self.search_plugin.config, "products", {}).get(
                "CAMS_EU_AIR_QUALITY_RE", {}
            )
        )
        default_values.pop("metadata_mapping", None)
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"
        params["download_format"] = "zip"  # override default in form file

        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIsNotNone(queryables)
        for element in form:
            if "details" not in element:
                # skip form elements missing details
                continue
            name: str = element["name"]
            ecmwf_name: str = f"ecmwf_{name}"
            details = element.get("details", {})
            default_by_params = params.get(name)
            default_by_form = details.get("default")
            if default_by_params is not None:
                # default value defined in the provider config
                self.assertIn(ecmwf_name, queryables.keys())
                self.assertFalse(
                    get_args(queryables[ecmwf_name])[1].is_required(),
                    f"Keyword {ecmwf_name} must be not required",
                )
                self.assertEqual(
                    default_by_params, get_args(queryables[ecmwf_name])[1].default
                )
            elif default_by_form is not None:
                # default value defined in the form file and not in the provider config
                self.assertIn(ecmwf_name, queryables.keys())
                self.assertFalse(
                    get_args(queryables[ecmwf_name])[1].is_required(),
                    f"Keyword {ecmwf_name} must be not required",
                )
                # default value defined as list in the form file
                self.assertEqual(
                    default_by_form[0], get_args(queryables[ecmwf_name])[1].default
                )

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_form_details_none(
        self, mock__fetch_data
    ):
        """Regression: discover_queryables must handle form elements with details set to None."""
        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)

        for element in form:
            if element.get("name") == "download_format":
                element["details"] = None
                break

        mock__fetch_data.side_effect = [constraints, form]

        default_values = deepcopy(
            getattr(self.search_plugin.config, "products", {}).get(
                "CAMS_EU_AIR_QUALITY_RE", {}
            )
        )
        default_values.pop("metadata_mapping", None)
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"

        queryables = self.search_plugin.discover_queryables(**params)

        self.assertIsNotNone(queryables)
        self.assertIn("ecmwf_download_format", queryables)
        self.assertTrue(
            get_args(queryables["ecmwf_download_format"])[1].is_required(),
            "Keyword ecmwf_download_format must be required when details are missing",
        )

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_not_allowed_parameters(
        self, mock__fetch_data
    ):
        """A not allowed parameter must not be listed in the queryables.

        A parameter is not allowed if all the following conditions are true:

        - the keyword is required by form;
        - the keyword is used by some constraint;
        - the list of available values is empty.

        In this case don't add the keyword to the list of queryables because
        it cannot be used with the given combination of parameters.
        """
        constraints = [
            {"date": ["2025-01-01/2025-06-01"], "variable": ["a", "b"]},
            {"date": ["2024-01-01/2024-12-01"]},
        ]
        form = [
            {"name": "date", "type": "StringListWidget", "required": True},
            {"name": "variable", "type": "StringListWidget", "required": True},
        ]
        mock__fetch_data.side_effect = [constraints, form]

        params = {
            "collection": "CAMS_EU_AIR_QUALITY_RE",
            "date": "2024-01-01",
        }
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIsNotNone(queryables)
        # ecmwf_date is queryable with a default value -> not required
        self.assertIn("ecmwf_date", queryables)
        self.assertFalse(
            get_args(queryables["ecmwf_date"])[1].is_required(),
            "Keyword ecmwf_date must be not required",
        )
        # ecmwf_variable is not queryable
        self.assertNotIn("ecmwf_variable", queryables)

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
        return_value={},
    )
    @mock.patch(
        "eodag.plugins.search.build_search_result.get_geometry_from_ecmwf_location",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.build_search_result.get_geometry_from_ecmwf_feature",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.build_search_result.get_geometry_from_ecmwf_area",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_convert_geometry_ok(
        self,
        mock_get_geometry_from_ecmwf_area,
        mock_get_geometry_from_ecmwf_feature,
        mock_get_geometry_from_ecmwf_location,
        mock__fetch_data,
    ):
        """Custom geometry must be converted to Shapely polygon."""
        area = [64.0, 51.0, 52.0, 63.0]
        shape = {
            "shape": [
                [50.0, 50.0],
                [50.0, 60.0],
                [60.0, 60.0],
                [60.0, 50.0],
                [50.0, 50.0],
            ],
            "type": "polygon",
        }
        location = {
            "latitude": 30.0,
            "longitude": 30.0,
        }

        # area
        params = {
            "collection": "CAMS_EU_AIR_QUALITY_RE",
            "area": area,
        }
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIn("geom", queryables)
        mock_get_geometry_from_ecmwf_area.assert_called_once_with(area)

        # shape
        params = {
            "collection": "CAMS_EU_AIR_QUALITY_RE",
            "feature": shape,
        }
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIn("geom", queryables)
        mock_get_geometry_from_ecmwf_feature.assert_called_once_with(shape)

        # location
        params = {
            "collection": "CAMS_EU_AIR_QUALITY_RE",
            "location": location,
        }
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIn("geom", queryables)
        mock_get_geometry_from_ecmwf_location.assert_called_once_with(location)

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
        return_value={},
    )
    def test_plugins_search_ecmwfsearch_convert_geometry_ko(
        self,
        mock__fetch_data,
    ):
        """
        ``get_geometry_from_ecmwf_*`` must raise an informative exception
        if the given geometry is not well formed.
        """
        # area
        area_values = [
            [1.0, 2.0, 3.0],  # not enough values
            [1.0, 2.0, 3.0, 4.0, 5.0],  # too many values
            [1.0, "lorem", 3.0, 4.0],  # wrong type
        ]
        for area in area_values:
            params = {
                "collection": "CAMS_EU_AIR_QUALITY_RE",
                "area": area,
            }
            with self.assertRaises(ValidationError, msg=area):
                self.search_plugin.discover_queryables(**params)

        # feature
        feature_values = [
            "foo",  # not a dict
            {"foo": "bar"},  # missing 'type'
            {"type": "points"},  # wrong 'type'
            {"type": "polygon"},  # missing 'shape'
            {"type": "polygon", "shape": "boo"},  # shape is not a list
            {
                "type": "polygon",
                "shape": [1, 2, 3],
            },
        ]
        for feature in feature_values:
            params = {
                "collection": "CAMS_EU_AIR_QUALITY_RE",
                "feature": feature,
            }
            with self.assertRaises(ValidationError, msg=feature):
                self.search_plugin.discover_queryables(**params)

        # location
        location_values = [
            "foo",  # not a dict
            {"lorem": "foo", "ipsum": "boo"},  # missing both fields
            {"latitude": 40, "ipsum": "boo"},  # missing one field
            {"latitude": 40, "longitude": "boo"},  # value is not a number
        ]
        for location in location_values:
            params = {
                "collection": "CAMS_EU_AIR_QUALITY_RE",
                "location": location,
            }
            with self.assertRaises(ValidationError, msg=location):
                self.search_plugin.discover_queryables(**params)

    @mock.patch("eodag.utils.requests.requests.sessions.Session.get", autospec=True)
    def test_plugins_search_ecmwf_search_wekeo_discover_queryables(
        self, mock_requests_get
    ):
        # One of the providers that has discover_queryables() configured with QueryStringSearch
        search_plugin = self.get_search_plugin(provider="wekeo_ecmwf")
        self.assertEqual("WekeoECMWFSearch", search_plugin.__class__.__name__)
        self.assertEqual(
            "ECMWFSearch",
            search_plugin.discover_queryables.__func__.__qualname__.split(".")[0],
        )

        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        wekeo_ecmwf_constraints = [constraints[0]]

        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        wekeo_ecmwf_form = form

        mock_requests_get.side_effect = [
            MockResponse(wekeo_ecmwf_constraints, status_code=200),
            MockResponse(wekeo_ecmwf_form, status_code=200),
        ]

        provider_queryables_from_constraints_file = [
            "ecmwf_year",
            "ecmwf_month",
            "ecmwf_day",
            "ecmwf_time",
            "ecmwf_variable",
            "ecmwf_leadtime_hour",
            "ecmwf_type",
            "ecmwf_product_type",
        ]

        queryables = search_plugin._get_collection_queryables(
            collection="ERA5_SL_MONTHLY", alias=None, filters={}
        )
        self.assertIsNotNone(queryables)

        self.assertEqual(len(mock_requests_get.call_args), 2)
        mock_requests_get.assert_any_call(
            mock.ANY,
            "https://cds.climate.copernicus.eu/api/catalogue/v1/collections/"
            "reanalysis-era5-single-levels-monthly-means/constraints.json",
            headers=USER_AGENT,
            auth=None,
            timeout=60,
        )
        mock_requests_get.assert_any_call(
            mock.ANY,
            "https://cds.climate.copernicus.eu/api/catalogue/v1/collections/"
            "reanalysis-era5-single-levels-monthly-means/form.json",
            headers=USER_AGENT,
            auth=None,
            timeout=60,
        )

        # queryables from provider constraints file are added (here the ones of ERA5_SL_MONTHLY for wekeo_ecmwf)
        for provider_queryable in provider_queryables_from_constraints_file:
            provider_queryable = (
                get_queryable_from_provider(
                    provider_queryable,
                    search_plugin.get_metadata_mapping("ERA5_SL_MONTHLY"),
                )
                or provider_queryable
            )
            self.assertIn(provider_queryable, queryables)

        # default properties in provider config are added and must be default values of the queryables
        for property, default_value in search_plugin.config.products[
            "ERA5_SL_MONTHLY"
        ].items():
            queryable = queryables.get(property)
            if queryable is not None:
                self.assertEqual(default_value, queryable.__metadata__[0].get_default())
                # queryables with default values are not required
                self.assertFalse(queryable.__metadata__[0].is_required())

        # queryables without default values are required
        queryable = queryables.get("month")
        if queryable is not None:
            self.assertEqual(PydanticUndefined, queryable.__metadata__[0].get_default())
            self.assertTrue(queryable.__metadata__[0].is_required())

        # check that queryable constraints from the constraints file are in queryable info
        # (here it is a case where all constraints of "variable" queryable can be taken into account)
        queryable = queryables.get("variable")
        if queryable is not None:
            variable_constraints = []
            for constraint in constraints:
                if "variable" in constraint:
                    variable_constraints.extend(constraint["variable"])
            # remove queryable constraints duplicates to make the assertion works
            self.assertSetEqual(
                set(variable_constraints), set(queryable.__origin__.__args__)
            )

        # reset mock
        mock_requests_get.reset_mock()

        # with additional param
        queryables = search_plugin.discover_queryables(
            collection="ERA5_SL_MONTHLY",
            dataset="EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS_MONTHLY_MEANS",
            **{"ecmwf:variable": "a"},
        )
        self.assertIsNotNone(queryables)

        self.assertEqual(12, len(queryables))
        # default properties called in function arguments are added and must be default values of the queryables
        queryable = queryables.get("ecmwf:variable")
        if queryable is not None:
            self.assertEqual("a", queryable.__metadata__[0].get_default())
            self.assertFalse(queryable.__metadata__[0].is_required())


class TestSearchPluginCopMarineSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginCopMarineSearch, self).setUp()
        self.provider = "cop_marine"
        self.product_data = {
            "id": "PRODUCT_A",
            "type": "Collection",
            "stac_version": "1.0.0",
            "stac_extensions": [
                "https://stac-extensions.github.io/scientific/v1.0.0/schema.json"
            ],
            "title": "Product A",
            "description": "A nice description",
            "license": "other",
            "providers": [
                {"name": "CLS (France)", "roles": ["producer"]},
                {
                    "name": "Copernicus Marine Service",
                    "roles": ["host", "processor"],
                    "url": "https://marine.copernicus.eu",
                },
            ],
            "keywords": [
                "oceanographic-geographical-features",
                "satellite-observation",
                "level-3",
            ],
            "links": [
                {
                    "rel": "root",
                    "href": "../catalog.stac.json",
                    "title": "Copernicus Marine Data Store",
                    "type": "application/json",
                },
                {
                    "rel": "parent",
                    "href": "../catalog.stac.json",
                    "title": "Copernicus Marine Data Store",
                    "type": "application/json",
                },
                {
                    "rel": "item",
                    "href": "dataset-number-one/dataset.stac.json",
                    "title": "dataset-number-one",
                    "type": "application/json",
                },
                {
                    "rel": "item",
                    "href": "dataset-number-two/dataset.stac.json",
                    "title": "dataset-number-two",
                    "type": "application/json",
                },
                {
                    "rel": "license",
                    "href": "https://marine.copernicus.eu/user-corner/service-commitments-and-licence",
                    "title": "Copernicus Marine Service Commitments and Licence",
                    "type": "text/html",
                },
            ],
            "extent": {
                "temporal": {
                    "interval": [
                        ["1970-01-01T00:00:00.000000Z", "1970-01-01T00:00:00.000000Z"]
                    ]
                },
                "spatial": {"bbox": [[0.0, 0.0, 0.0, 0.0]]},
            },
            "assets": {
                "thumbnail": {
                    "href": "https://catalogue.marine.copernicus.eu/documents/IMG/WAVE_GLO_PHY_SPC_L3_MY_014_006.png",
                    "type": "image/png",
                    "roles": ["thumbnail"],
                    "title": "GLOBAL OCEAN L3 SPECTRAL PARAMETERS FROM REPROCESSED SATELLITE MEASUREMENTS thumbnail",
                }
            },
            "properties": {
                "altId": "450bf368-2407-4c2c-8535-f215a4cda963",
                "created": "2021-04-23",
                "modifiedDate": "2021-04-23",
                "contacts": [
                    {
                        "name": "Jim Gabriel",
                        "organisationName": "Heaven Inc",
                        "responsiblePartyRole": "custodian",
                        "email": "jim.gabriel@heaven.com",
                    }
                ],
                "projection": "WGS84 / Simple Mercator (EPSG:41001)",
                "formats": ["NetCDF-4"],
                "featureTypes": ["Swath", "Trajectory"],
                "tempResolutions": ["Instantaneous"],
                "rank": 15015,
                "areas": [
                    "Global Ocean",
                ],
                "times": ["Past"],
                "sources": ["Satellite observations"],
                "colors": ["Blue Ocean"],
                "directives": [
                    "Water Framework Directive (WFD)",
                    "Maritime Spatial Planning (MSP)",
                ],
                "crs": "EPSG:3857",
                "isStaging": False,
                "admp_updated": "2023-11-07T16:54:54.688320Z",
            },
            "sci:doi": "10.48670/moi-00174",
        }
        self.dataset1_data = {
            "id": "dataset-number-one",
            "type": "Feature",
            "stac_version": "1.0.0",
            "stac_extensions": [
                "https://stac-extensions.github.io/datacube/v2.1.0/schema.json"
            ],
            "geometry": {
                "type": "Polygon",
                "coordinates": [
                    [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]
                ],
            },
            "bbox": [0.0, 0.0, 0.0, 0.0],
            "properties": {
                "title": "dataset-number-one",
                "datetime": "1970-01-01T00:00:00.000000Z",
            },
            "links": [
                {
                    "rel": "root",
                    "href": "../../catalog.stac.json",
                    "title": "Copernicus Marine Data Store",
                    "type": "application/json",
                },
                {
                    "rel": "parent",
                    "href": "../product.stac.json",
                    "title": "PRODUCT A",
                    "type": "application/json",
                },
                {
                    "rel": "collection",
                    "href": "../product.stac.json",
                    "title": "PRODUCT A",
                    "type": "application/json",
                },
            ],
            "assets": {
                "native": {
                    "id": "native",
                    "href": "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one",
                    "type": "application/x-netcdf",
                    "roles": ["data"],
                    "title": "Native dataset",
                    "description": "The original, non-ARCO version of this dataset, as published by the data provider.",
                }
            },
            "collection": "PRODUCT_A",
        }
        self.dataset2_data = deepcopy(self.dataset1_data)
        self.dataset2_data["id"] = "dataset-number-two"
        self.dataset2_data["properties"]["title"] = "dataset-number-two"
        self.dataset2_data["assets"]["native"][
            "href"
        ] = "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-two"

        self.list_objects_response1 = {
            "Contents": [
                {
                    "Key": "native/PRODUCT_A/dataset-number-one/item_20200102_20200103_hdkIFE.KFNEDNF_20210101.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-one/item_20200104_20200105_hdkIFEKFNEDNF_20210101.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-one/item_20200302_20200303_hdkIFEKFNEDN_20210101.nc"
                },
            ]
        }
        self.list_objects_response2 = {
            "Contents": [
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_20200102_20200103_fizncnqijei_20210101.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_20200204_20200205_niznjvnqkrf_20210101.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_20200302_20200303_fIZHVCOINine_20210101.nc"
                },
            ]
        }
        self.list_objects_response3 = {
            "Contents": [
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_15325642_fizncnqijei.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_846282_niznjvnqkrf.nc"
                },
            ]
        }
        self.s3 = boto3.client(
            "s3",
            config=botocore.config.Config(
                # Configures to use subdomain/virtual calling format.
                s3={"addressing_style": "virtual"},
                signature_version=botocore.UNSIGNED,
            ),
        )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_query_with_dates(self, mock_requests_get):
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)

        with mock.patch("eodag.plugins.search.cop_marine._get_s3_client") as s3_stub:
            s3_stub.return_value = self.s3
            stubber = Stubber(self.s3)
            stubber.add_response(
                "list_objects",
                self.list_objects_response1,
                {
                    "Bucket": "bucket1",
                    "Prefix": "native/PRODUCT_A/dataset-number-one",
                },
            )
            stubber.add_response(
                "list_objects",
                self.list_objects_response2,
                {
                    "Bucket": "bucket1",
                    "Prefix": "native/PRODUCT_A/dataset-number-two",
                },
            )
            stubber.activate()
            result = search_plugin.query(
                collection="PRODUCT_A",
                start_datetime="2020-01-01T01:00:00Z",
                end_datetime="2020-02-01T01:00:00Z",
            )
            mock_requests_get.assert_has_calls(
                calls=[
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/product.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-one/dataset.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-two/dataset.stac.json"
                    ),
                    call().json(),
                ]
            )
            self.assertEqual(3, result.number_matched)
            products_dataset1 = [
                product
                for product in result.data
                if product.properties["dataset"] == "dataset-number-one"
            ]
            products_dataset2 = [
                product
                for product in result.data
                if product.properties["dataset"] == "dataset-number-two"
            ]
            self.assertEqual(2, len(products_dataset1))
            self.assertEqual(1, len(products_dataset2))
            self.assertEqual(
                "2020-01-02T00:00:00.000Z",
                products_dataset2[0].properties["start_datetime"],
            )
            self.assertEqual(
                "2020-01-03T00:00:00.000Z",
                products_dataset2[0].properties["end_datetime"],
            )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_query_no_dates_in_id(self, mock_requests_get):
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]

        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        search_plugin.config.products = {
            "PRODUCT_A": {
                "_collection": "PRODUCT_A",
                "code_mapping": {"param": "platform", "index": 1},
            }
        }

        with mock.patch("eodag.plugins.search.cop_marine._get_s3_client") as s3_stub:
            s3_stub.return_value = self.s3
            stubber = Stubber(self.s3)
            stubber.add_response(
                "list_objects",
                self.list_objects_response1,
                {"Bucket": "bucket1", "Prefix": "native/PRODUCT_A/dataset-number-one"},
            )
            stubber.add_response(
                "list_objects",
                self.list_objects_response3,
                {"Bucket": "bucket1", "Prefix": "native/PRODUCT_A/dataset-number-two"},
            )
            stubber.add_response(
                "list_objects",
                {},
                {
                    "Bucket": "bucket1",
                    "Prefix": "native/PRODUCT_A/dataset-number-two",
                    "Marker": "native/PRODUCT_A/dataset-number-two/item_846282_niznjvnqkrf.nc",
                },
            )
            stubber.activate()
            result = search_plugin.query(
                collection="PRODUCT_A",
                start_datetime="1969-01-01T01:00:00Z",
                end_datetime="1970-02-01T01:00:00Z",
            )
            mock_requests_get.assert_has_calls(
                calls=[
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/product.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-one/dataset.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-two/dataset.stac.json"
                    ),
                    call().json(),
                ]
            )
            self.assertEqual(2, result.number_matched)
            products_dataset2 = [
                product
                for product in result.data
                if product.properties["dataset"] == "dataset-number-two"
            ]
            self.assertEqual(2, len(products_dataset2))
            self.assertEqual(
                "1970-01-01T00:00:00.000000Z",
                products_dataset2[0].properties["start_datetime"],
            )
            self.assertEqual(
                "1970-01-01T00:00:00.000000Z",
                products_dataset2[0].properties["end_datetime"],
            )
            self.assertEqual(
                "15325642",
                products_dataset2[0].properties["platform"],
            )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_query_with_id(self, mock_requests_get):
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]

        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)

        with mock.patch("eodag.plugins.search.cop_marine._get_s3_client") as s3_stub:
            s3_stub.return_value = self.s3
            stubber = Stubber(self.s3)
            for i in [
                0,
                1,
            ]:  # add responses twice because 2 search requests will be executed
                stubber.add_response(
                    "list_objects",
                    self.list_objects_response1,
                    {
                        "Bucket": "bucket1",
                        "Prefix": "native/PRODUCT_A/dataset-number-one",
                    },
                )
                stubber.add_response(
                    "list_objects",
                    {},
                    {
                        "Bucket": "bucket1",
                        "Marker": "native/PRODUCT_A/dataset-number-one/item_20200302_20200303_hdkIFEKFNEDN_20210101.nc",
                        "Prefix": "native/PRODUCT_A/dataset-number-one",
                    },
                )
                stubber.add_response(
                    "list_objects",
                    self.list_objects_response2,
                    {
                        "Bucket": "bucket1",
                        "Prefix": "native/PRODUCT_A/dataset-number-two",
                    },
                )
            stubber.activate()
            result = search_plugin.query(
                collection="PRODUCT_A",
                id="item_20200204_20200205_niznjvnqkrf_20210101",
            )
            self.assertEqual(1, result.number_matched)
            self.assertEqual(
                "item_20200204_20200205_niznjvnqkrf_20210101",
                result.data[0].properties["id"],
            )
            result = search_plugin.query(
                collection="PRODUCT_A",
                id="item_20200102_20200103_hdkIFE.KFNEDNF_20210101",
            )
            self.assertEqual(1, result.number_matched)
            self.assertEqual(
                "item_20200102_20200103_hdkIFE.KFNEDNF_20210101",
                result.data[0].properties["id"],
            )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_search_by_id_no_dates_in_id(
        self, mock_requests_get
    ):
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]

        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        search_plugin.config.products = {
            "PRODUCT_A": {
                "_collection": "PRODUCT_A",
                "code_mapping": {"param": "platform", "index": 1},
            }
        }

        with mock.patch("eodag.plugins.search.cop_marine._get_s3_client") as s3_stub:
            s3_stub.return_value = self.s3
            stubber = Stubber(self.s3)
            stubber.add_response(
                "list_objects",
                self.list_objects_response1,
                {"Bucket": "bucket1", "Prefix": "native/PRODUCT_A/dataset-number-one"},
            )
            stubber.add_response(
                "list_objects",
                {},
                {
                    "Bucket": "bucket1",
                    "Marker": "native/PRODUCT_A/dataset-number-one/item_20200302_20200303_hdkIFEKFNEDN_20210101.nc",
                    "Prefix": "native/PRODUCT_A/dataset-number-one",
                },
            )
            stubber.add_response(
                "list_objects",
                self.list_objects_response3,
                {"Bucket": "bucket1", "Prefix": "native/PRODUCT_A/dataset-number-two"},
            )
            stubber.add_response(
                "list_objects",
                {},
                {
                    "Bucket": "bucket1",
                    "Prefix": "native/PRODUCT_A/dataset-number-two",
                    "Marker": "native/PRODUCT_A/dataset-number-two/item_846282_niznjvnqkrf.nc",
                },
            )
            stubber.activate()
            result = search_plugin.query(
                collection="PRODUCT_A", id="item_846282_niznjvnqkrf"
            )
            mock_requests_get.assert_has_calls(
                calls=[
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/product.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-one/dataset.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-two/dataset.stac.json"
                    ),
                    call().json(),
                ]
            )
            self.assertEqual(1, result.number_matched)
            self.assertEqual("item_846282_niznjvnqkrf", result.data[0].properties["id"])

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_query_with_not_intersected_geom(
        self, mock_requests_get
    ):
        """A query with a geometry that does not intersect the dataset geometries must return no result"""
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]

        geometry = get_geometry_from_various(geometry=[10, 20, 30, 40])

        # check that "geometry" does not intersect the dataset geometries
        self.assertFalse(
            get_geometry_from_various(
                geometry=self.dataset1_data["geometry"]
            ).intersects(geometry)
        )
        self.assertFalse(
            get_geometry_from_various(
                geometry=self.dataset2_data["geometry"]
            ).intersects(geometry)
        )

        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)

        result = search_plugin.query(
            collection="PRODUCT_A",
            geometry=geometry,
        )

        mock_requests_get.assert_has_calls(
            calls=[
                call(
                    "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/product.stac.json"
                ),
                call().json(),
                call(
                    "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-one/dataset.stac.json"
                ),
                call().json(),
                call(
                    "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-two/dataset.stac.json"
                ),
                call().json(),
            ]
        )

        # check that no result has been found
        self.assertListEqual(result.data, [])
        self.assertEqual(result.number_matched, 0)

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_with_errors(self, mock_requests_get):
        exc = requests.RequestException()
        exc.errno = 404
        mock_requests_get.side_effect = exc
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        with self.assertRaises(UnsupportedCollection):
            search_plugin.query(
                collection="PRODUCT_AX",
                id="item_20200204_20200205_niznjvnqkrf_20210101",
            )
        mock_requests_get.reset()
        mock_requests_get.side_effect = requests.exceptions.ConnectionError()
        with self.assertRaises(RequestError):
            search_plugin.query(
                collection="PRODUCT_A",
                id="item_20200204_20200205_niznjvnqkrf_20210101",
            )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_normalize_results(self, mock_requests_get):
        """Normalized query results must include asset information fetched from S3"""
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)

        asset_filename = "20200115_dm-metno-MODEL-topaz5_ecosmo-ARC-b20200115-fv02.0.nc"

        def mock_s3_list_objects(self, operation_name, *args, **kwargs):
            if operation_name != "ListObjects":
                raise NotImplementedError()
            params = {**args[0], **kwargs}
            prefix = params.get("Prefix", "")
            if prefix == "native/PRODUCT_A/dataset-number-one" and not params.get(
                "Marker"
            ):
                contents = [
                    {
                        "Key": f"{prefix}/{asset_filename}",
                        "LastModified": dt.datetime(
                            2020, 1, 15, tzinfo=dt.timezone.utc
                        ),
                        "ETag": '"d41d8cd98f00b204e9800998ecf8427e"',
                        "Size": 666,
                        "StorageClass": "STANDARD",
                        "Owner": {
                            "DisplayName": "Data Access",
                            "ID": "data-access",
                        },
                    }
                ]
            else:
                contents = []
            return {"IsTruncated": False, "Contents": contents}

        with mock.patch(
            "botocore.client.BaseClient._make_api_call", new=mock_s3_list_objects
        ):
            result = search_plugin.query(
                collection="PRODUCT_A",
                start_datetime="2020-01-01T01:00:00Z",
                end_datetime="2020-02-01T01:00:00Z",
            )

        self.assertIn("native", result[0].assets)
        asset = result[0].assets["native"]
        self.assertEqual(asset.get("title"), "native")
        self.assertEqual(
            asset.get("href"),
            "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one/"
            + asset_filename,
        )
        self.assertEqual(asset.get("type"), "application/x-netcdf")  # hard configured
        self.assertEqual(asset.get("file:size"), 666)
        self.assertEqual(asset.get("file:checksum"), "d41d8cd98f00b204e9800998ecf8427e")
        self.assertEqual(asset.get("cop_marine:owner_id"), "data-access")
        self.assertEqual(asset.get("cop_marine:owner_name"), "Data Access")

    def test_plugins_search_cop_marine_create_product(self):
        """Test creation of EOProduct from input data"""
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        s3_url = "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one"
        item_key = "PRODUCT_A/dataset-number-one/item1_20010501"
        collection_dict = {
            "id": "PRODUCT_A",
            "properties": {"a": "a", "b": "b"},
            "assets": {"thumbnail": {"href": "https://www.abc.com/thumbnailA"}},
        }
        dataset_dict = {
            "id": "dataset-number-one",
            "properties": {
                "c": "c",
                "start_datetime": "2000-01-01T00:00:00Z",
                "end_datetime": "2010-01-01T00:00:00Z",
            },
        }
        product = search_plugin._create_product(
            "PRODUCT_A", item_key, s3_url, dataset_dict, collection_dict
        )
        self.assertEqual("item1_20010501", product.properties["id"])
        self.assertEqual("PRODUCT_A", product.collection)
        self.assertEqual(
            "2001-05-01T00:00:00.000Z", product.properties["start_datetime"]
        )
        self.assertEqual("2001-05-01T00:00:00.000Z", product.properties["end_datetime"])
        self.assertEqual(
            "https://www.abc.com/thumbnailA", product.properties["eodag:thumbnail"]
        )
        self.assertEqual("dataset-number-one", product.properties["dataset"])
        self.assertEqual("a", product.properties["a"])
        self.assertEqual("b", product.properties["b"])
        self.assertEqual("c", product.properties["c"])
        self.assertIn("native", product.assets)
        self.assertEqual(
            "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one/item1_20010501",
            product.assets["native"]["href"],
        )

        # with use of dataset dates
        product = search_plugin._create_product(
            "PRODUCT_A", item_key, s3_url, dataset_dict, collection_dict, True
        )
        self.assertEqual("item1_20010501", product.properties["id"])
        self.assertEqual("PRODUCT_A", product.collection)
        self.assertEqual("2000-01-01T00:00:00Z", product.properties["start_datetime"])
        self.assertEqual("2010-01-01T00:00:00Z", product.properties["end_datetime"])

        # with asset properties
        product = search_plugin._create_product(
            "PRODUCT_A",
            item_key,
            s3_url,
            dataset_dict,
            collection_dict,
            asset_properties={"z": "z"},
        )
        self.assertEqual("item1_20010501", product.properties["id"])
        self.assertIn("native", product.assets)
        self.assertEqual(
            "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one/item1_20010501",
            product.assets["native"]["href"],
        )
        self.assertIn("z", product.assets["native"])
        self.assertEqual("z", product.assets["native"]["z"])

    def test_plugins_search_cop_marine_discover_queryables(self):
        """Queryables discovery with a CopMarineSearch must return static queryables with an adaptative default value"""  # noqa
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        kwargs = {"collection": "PRODUCT_A", "provider": self.provider}

        queryables = search_plugin.discover_queryables(**kwargs)

        self.assertIsNotNone(queryables)
        # check that the queryables are the ones expected (they are always the same ones)
        self.assertListEqual(
            list(queryables.keys()), ["collection", "id", "start", "end", "geom"]
        )
        # check that each queryable does not have a default value except the one set in the kwargs
        for key, queryable in queryables.items():
            if key in kwargs:
                self.assertIsNotNone(queryable.__metadata__[0].get_default())
            else:
                self.assertIsNone(queryable.__metadata__[0].get_default())


class TestSearchPluginWekeoSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginWekeoSearch, self).setUp()
        # One of the providers that has a WekeoSearch Search plugin
        provider = "wekeo_main"
        self.wekeomain_search_plugin = self.get_search_plugin(self.collection, provider)
        self.wekeomain_auth_plugin = self.get_auth_plugin(self.wekeomain_search_plugin)

    def test_plugins_search_wekeosearch_init_wekeomain(self):
        """Check that the WekeoSearch plugin is initialized correctly for wekeo_main provider"""

        default_config = load_default_config()["wekeo_main"]
        # "eodag:order_link" in S1_SAR_GRD but not in provider conf or S1_SAR_SLC conf
        self.assertNotIn("eodag:order_link", default_config.search.metadata_mapping)
        self.assertIn(
            "eodag:order_link",
            default_config.products["S1_SAR_GRD"]["metadata_mapping"],
        )
        self.assertNotIn("metadata_mapping", default_config.products["S1_SAR_SLC"])

        # metadata_mapping_from_product: from S1_SAR_GRD to S1_SAR_SLC
        self.assertEqual(
            default_config.products["S1_SAR_SLC"]["metadata_mapping_from_product"],
            "S1_SAR_GRD",
        )

        # check initialized plugin configuration
        self.assertDictEqual(
            self.wekeomain_search_plugin.config.products["S1_SAR_GRD"][
                "metadata_mapping"
            ],
            self.wekeomain_search_plugin.config.products["S1_SAR_SLC"][
                "metadata_mapping"
            ],
        )

        # S3_SRA_BS has both metadata_mapping_from_product and metadata_mapping
        # "metadata_mapping" must override "metadata_mapping_from_product"
        self.assertIn(
            "eodag:order_link",
            default_config.products["S3_SRA_BS"]["metadata_mapping"],
        )
        self.assertIn(
            "eodag:order_link",
            default_config.products["S3_EFR"]["metadata_mapping"],
        )
        self.assertEqual(
            default_config.products["S3_SRA_BS"]["metadata_mapping_from_product"],
            "S3_EFR",
        )
        self.assertNotEqual(
            self.wekeomain_search_plugin.config.products["S3_SRA_BS"][
                "metadata_mapping"
            ]["eodag:order_link"],
            self.wekeomain_search_plugin.config.products["S3_EFR"]["metadata_mapping"][
                "eodag:order_link"
            ],
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.StacSearch.build_query_string", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.PostJsonSearch.build_query_string", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.WekeoSearch._request",
        autospec=True,
    )
    def test_plugins_search_wekeosearch_search_wekeomain(
        self,
        mock__request,
        mock_build_qs_postjsonsearch,
        mock_build_qs_stacsearch,
        mock_normalize_results,
    ):
        """A query with a WekeoSearch provider (here wekeo_main) must use build_query_string() of PostJsonSearch"""  # noqa
        mock_build_qs_postjsonsearch.return_value = (
            mock_build_qs_stacsearch.return_value
        ) = (
            {
                "dataset_id": "EO:ESA:DAT:SENTINEL-2",
                "startDate": "2020-08-08",
                "completionDate": "2020-08-16",
                "bbox": [137.772897, 13.134202, 153.749135, 23.885986],
                "processing:level": "S2MSI1C",
            },
            mock.ANY,
        )

        self.wekeomain_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth_plugin=self.wekeomain_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        mock__request.assert_called()
        mock_build_qs_postjsonsearch.assert_called()
        mock_build_qs_stacsearch.assert_not_called()

    def test_plugins_search_wekeosearch_search_wekeomain_ko(self):
        """A query with a parameter which is not queryable must
        raise an error if the provider does not allow it"""  # noqa
        # with raised error parameter set to True in the global config of the provider
        provider_search_plugin_config = copy_deepcopy(
            self.wekeomain_search_plugin.config
        )
        self.wekeomain_search_plugin.config.discover_metadata = {
            "raise_mtd_discovery_error": True
        }

        with self.assertRaises(ValidationError) as context:
            self.wekeomain_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth_plugin=self.wekeomain_auth_plugin,
                ),
                **{**self.search_criteria_s2_msi_l1c, **{"foo": "bar"}},
            )
        self.assertEqual(
            "Search parameters which are not queryable are disallowed for this collection on this provider: "
            f"please remove 'foo' from your search parameters. Collection: "
            f"{self.search_criteria_s2_msi_l1c['collection']} / provider : {self.wekeomain_search_plugin.provider}",
            context.exception.message,
        )

        # with raised error parameter set to True in the config of the collection of the provider

        # first, update this parameter to False in the global config
        # to show that it is going to be taken over by this new config
        self.wekeomain_search_plugin.config.discover_metadata[
            "raise_mtd_discovery_error"
        ] = False

        self.wekeomain_search_plugin.config.products[
            self.search_criteria_s2_msi_l1c["collection"]
        ]["discover_metadata"] = {"raise_mtd_discovery_error": True}

        with self.assertRaises(ValidationError) as context:
            self.wekeomain_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth_plugin=self.wekeomain_auth_plugin,
                ),
                **{**self.search_criteria_s2_msi_l1c, **{"foo": "bar"}},
            )
        self.assertEqual(
            "Search parameters which are not queryable are disallowed for this collection on this provider: "
            f"please remove 'foo' from your search parameters. Collection: "
            f"{self.search_criteria_s2_msi_l1c['collection']} / provider : {self.wekeomain_search_plugin.provider}",
            context.exception.message,
        )

        # restore the original config
        self.wekeomain_search_plugin.config = provider_search_plugin_config

    @mock.patch(
        "eodag.plugins.search.qssearch.PostJsonSearch.discover_queryables",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.StacSearch.discover_queryables", autospec=True
    )
    def test_plugins_search_postjsonsearch_discover_queryables(
        self,
        mock_stacsearch_discover_queryables,
        mock_postjsonsearch_discover_queryables,
    ):
        """Queryables discovery with a PostJsonSearchWithStacQueryables (here wekeo_main) must use discover_queryables() of StacSearch"""  # noqa
        self.wekeomain_search_plugin.discover_queryables(collection=self.collection)
        mock_stacsearch_discover_queryables.assert_called()
        mock_postjsonsearch_discover_queryables.assert_not_called()

    @mock.patch(
        "eodag.plugins.search.qssearch.StacSearch.discover_queryables",
        autospec=True,
    )
    def test_plugins_search_oar_discover_queryables(
        self,
        mock_stacsearch_discover_queryables,
    ):
        """OARSearch must reuse StacSearch queryables discovery and add defaults."""
        OARSearch = import_module("eodag.plugins.search.oar").OARSearch

        search_plugin = OARSearch.__new__(OARSearch)
        mock_stacsearch_discover_queryables.return_value = {
            "foo": Annotated[str, Field(None, description="Foo param")]
        }

        queryables_dict = search_plugin.discover_queryables(collection=self.collection)

        self.assertEqual(
            set(queryables_dict.keys()), set(["foo", "id", "start", "end", "geom"])
        )
        self.assertTrue(
            all(isinstance(get_args(q)[1], FieldInfo) for q in queryables_dict.values())
        )
        self.assertEqual(get_args(queryables_dict["foo"])[1].description, "Foo param")
        mock_stacsearch_discover_queryables.assert_called_once_with(
            search_plugin, collection=self.collection
        )

    def test_plugins_search_oar_preconfigured_defaults(self):
        """OARSearch must expose OGC API - Records search defaults."""
        from eodag.config import PluginConfig

        OARSearch = import_module("eodag.plugins.search.oar").OARSearch

        config = PluginConfig()
        config.api_endpoint = "https://example.test"
        config.metadata_mapping = {
            "q": ["custom_q={q}", "$.properties.title"],
        }
        config.pagination = {"total_items_nb_key_path": "$.matched"}
        config.sort = {
            "sort_by_tpl": "&custom_sort={sort_param}",
            "sort_order_mapping": {"ascending": "asc", "descending": "desc"},
        }
        config.discover_queryables = {"fetch_url": "https://example.test/queryables"}
        config.products = {}

        plugin = OARSearch("dummy", config)

        self.assertIn("q", plugin.config.metadata_mapping)
        self.assertEqual(plugin.config.metadata_mapping["q"][0], "custom_q={q}")
        self.assertIn("title", plugin.config.metadata_mapping)
        self.assertIn("datetime", plugin.config.metadata_mapping)
        self.assertIn("datetime", str(plugin.config.metadata_mapping["datetime"]))
        self.assertIn("updated", plugin.config.metadata_mapping)

        self.assertIn(
            "matched", str(plugin.config.pagination["total_items_nb_key_path"])
        )
        self.assertIn("next_page_url_tpl", plugin.config.pagination)
        self.assertIn("next_page_token_key", plugin.config.pagination)
        self.assertEqual(plugin.config.pagination["next_page_token_key"], "skip")

        self.assertEqual(plugin.config.sort["sort_by_tpl"], "&custom_sort={sort_param}")
        self.assertEqual(plugin.config.sort["sort_order_mapping"]["ascending"], "asc")
        self.assertEqual(plugin.config.sort["sort_order_mapping"]["descending"], "desc")

        self.assertEqual(
            plugin.config.api_endpoint,
            "https://example.test/collections/{_collection}/items",
        )
        self.assertEqual(
            plugin.config.discover_queryables["fetch_url"],
            "https://example.test/queryables",
        )
        self.assertEqual(
            plugin.config.discover_queryables["collection_fetch_url"],
            "https://example.test/collections/{provider_collection}/queryables",
        )


class TestSearchPluginDedtLumi(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginDedtLumi, self).setUp()
        self.provider = "dedt_lumi"
        self.search_plugin = self.get_search_plugin(provider=self.provider)
        self.collection = "DT_CLIMATE_ADAPTATION"

    def test_plugins_apis_dedt_lumi_query_feature(self):
        """Test the proper handling of geom into ecmwf:feature"""

        # Search using geometry
        _expected_feature = {
            "shape": [[43.0, 1.0], [44.0, 1.0], [44.0, 2.0], [43.0, 2.0], [43.0, 1.0]],
            "type": "polygon",
        }
        results = self.search_plugin.query(
            collection=self.collection,
            start="2021-01-01",
            geometry={"lonmin": 1, "latmin": 43, "lonmax": 2, "latmax": 44},
        )
        eoproduct = results.data[0]

        self.assertDictEqual(_expected_feature, eoproduct.properties["qs"]["feature"])

        # Unsupported multi-polygon
        with self.assertRaises(ValidationError):
            self.search_plugin.query(
                collection=self.collection,
                start="2021-01-01",
                geometry="""MULTIPOLYGON (
                    ((1.23 43.42, 1.23 43.76, 1.68 43.76, 1.68 43.42, 1.23 43.42)),
                    ((2.23 43.42, 2.23 43.76, 3.68 43.76, 3.68 43.42, 2.23 43.42))
                )""",
            )


class TestSearchPluginCopGhslSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginCopGhslSearch, self).setUp()
        self.constraints = [
            {
                "year": ["2000", "2005", "2010"],
                "proj:code": ["EPSG:54009"],
                "tile_size": ["10m"],
            },
            {
                "year": ["2000", "2005", "2010"],
                "proj:code": ["EPSG:4326"],
                "tile_size": ["3ss"],
            },
        ]

    def test_plugins_search_cop_ghsl_convert_bbox(self):
        """test if bboxes given in letres are converted correctly to degrees"""
        # Mollweide coordinate system (ESRI:54009)
        bbox_mollweide = ["-6 041 000", "7 000 000", "-5 041 000", "6 000 000"]
        bbox_degrees = _convert_bbox_to_lonlat_mollweide(bbox_mollweide)
        expected_bbox = [-95.57, 61.3, -67.36, 51.21]
        for i, num in enumerate(bbox_degrees):
            self.assertEqual(expected_bbox[i], round(num, 2))
        # Mollweide point outside of map
        bbox_mollweide = ["-6 041 000", "9 000 000", "-5 041 000", "8 000 000"]
        bbox_degrees = _convert_bbox_to_lonlat_mollweide(bbox_mollweide)
        expected_bbox = [-180, 89.09, -108.89, 72.77]
        for i, num in enumerate(bbox_degrees):
            self.assertEqual(expected_bbox[i], round(num, 2))

        # ETRS89/LAEA Europe coordinate system (EPSG:3035)
        bbox_3035 = ["1,944,000", "1,042,000", "2,044,000", "942,000"]
        bbox_degrees = _convert_bbox_to_lonlat_EPSG3035(bbox_3035)
        expected_bbox = [-14.34, 28.99, -13.11, 28.39]
        for i, num in enumerate(bbox_degrees):
            self.assertEqual(expected_bbox[i], round(num, 2))
        bbox_3035 = ["4,744,000", "1,542,000", "4,844,000", "1,442,000"]
        bbox_degrees = _convert_bbox_to_lonlat_EPSG3035(bbox_3035)
        expected_bbox = [14.7, 36.83, 15.74, 35.86]
        for i, num in enumerate(bbox_degrees):
            self.assertEqual(expected_bbox[i], round(num, 2))

    def test_plugins_search_cop_ghsl_get_available_values_from_constraints(self):
        """test if get_available_values_from_contraints returns the available values for
        the given filters based on the given constraints and throws an error if no values are available"""
        # invalid parameter in filter
        with self.assertRaises(ValidationError):
            _get_available_values_from_constraints(
                self.constraints, {"bla": "1", "year": "2000"}, "PT1"
            )
        # invalid value for parameter
        with self.assertRaises(ValidationError):
            _get_available_values_from_constraints(
                self.constraints,
                {"proj:code": "EPSG:54009", "year": "2020", "tile_size": "10m"},
                "PT1",
            )
        # invalid combination
        with self.assertRaises(ValidationError):
            _get_available_values_from_constraints(
                self.constraints,
                {"proj:code": "EPSG:54009", "year": "2000", "tile_size": "3ss"},
                "PT1",
            )
        # timespan without valid value for year
        with self.assertRaises(ValidationError):
            _get_available_values_from_constraints(
                self.constraints,
                {
                    "proj:code": "EPSG:54009",
                    "year": ["2011", "2012"],
                    "tile_size": "10m",
                },
                "PT1",
            )

        # valid request for one year
        available_values = _get_available_values_from_constraints(
            self.constraints,
            {"proj:code": "EPSG:54009", "year": "2000", "tile_size": "10m"},
            "PT1",
        )
        expected_available = {
            "year": ["2000", "2005", "2010"],
            "proj:code": ["EPSG:54009"],
            "tile_size": ["10m"],
        }
        self.assertDictEqual(expected_available, available_values)
        # valid request for a time span
        available_values = _get_available_values_from_constraints(
            self.constraints,
            {
                "proj:code": "EPSG:54009",
                "year": ["1999", "2000", "2001"],
                "tile_size": "10m",
            },
            "PT1",
        )
        expected_available = {
            "year": ["2000"],
            "proj:code": ["EPSG:54009"],
            "tile_size": ["10m"],
        }
        self.assertDictEqual(expected_available, available_values)

    def test_plugins_search_cop_ghsl_replace_datetimes(self):
        """test replacement of datetime parameters in the request by year/month
        months should only be calculated if start year and end year are the same
        """
        # no year given, timespan for several years
        params = {
            "start_datetime": "2020-01-01T00:00:00Z",
            "end_datetime": "2023-01-01T00:00:00Z",
        }
        _replace_datetimes(params)
        self.assertIn("year", params)
        self.assertListEqual(["2020", "2021", "2022", "2023"], params["year"])
        # different date format
        params = {"start_datetime": "2020-01-01", "end_datetime": "2023-01-01"}
        _replace_datetimes(params)
        self.assertIn("year", params)
        self.assertListEqual(["2020", "2021", "2022", "2023"], params["year"])
        # datetimes and year given -> keep year
        params = {
            "start_datetime": "2020-01-01T00:00:00Z",
            "end_datetime": "2023-01-01T00:00:00Z",
            "year": "2019",
        }
        _replace_datetimes(params)
        self.assertIn("year", params)
        self.assertEqual("2019", params["year"])

        # one year -> include months
        params = {
            "start_datetime": "2020-08-01T00:00:00Z",
            "end_datetime": "2020-11-01T00:00:00Z",
        }
        _replace_datetimes(params)
        self.assertIn("year", params)
        self.assertListEqual(["2020"], params["year"])
        self.assertIn("month", params)
        self.assertListEqual(["08", "09", "10", "11"], params["month"])

    @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
    def test_plugins_search_cop_ghsl_check_input_parameters_valid(
        self, mock_fetch_constraints
    ):
        """test if the input parameters are correctly validated"""
        mock_fetch_constraints.return_value = {"constraints": self.constraints}
        plugin = next(self.plugins_manager.get_search_plugins(provider="cop_ghsl"))
        # missing parameter
        input_params = {"year": "2020", "proj:code": "EPSG:54009"}
        with self.assertRaises(ValidationError):
            plugin._check_input_parameters_valid("PT1", input_params)

        # valid input with one year
        input_params = {"year": "2000", "proj:code": "EPSG:54009", "tile_size": "10m"}
        plugin._check_input_parameters_valid(
            "PT1", input_params
        )  # nothing should be raised
        # valid input several years
        input_params = {
            "year": ["1999", "2000", "2001"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
        }
        plugin._check_input_parameters_valid("PT1", input_params)
        expected_params = {
            "year": ["2000"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
        }
        # year in input params should be updated
        self.assertDictEqual(expected_params, input_params)

        # valid input with grouped_by param
        constraints = [
            {
                "year": ["2000", "2005", "2010"],
                "month": ["01", "02", "03"],
                "proj:code": ["EPSG:54009"],
                "tile_size": ["10m"],
            },
            {
                "year": ["2000", "2005", "2010"],
                "month": ["01", "02", "03"],
                "proj:code": ["EPSG:4326"],
                "tile_size": ["3ss"],
            },
        ]
        mock_fetch_constraints.return_value = {"constraints": constraints}
        input_params = {
            "year": "2000",
            "month": ["02", "03", "04"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
            "grouped_by": "month",
        }
        plugin._check_input_parameters_valid("PT1", input_params)
        expected_params = {
            "year": "2000",
            "month": ["02", "03"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
        }
        self.assertDictEqual(expected_params, input_params)
        # valid input with grouped_by param, grouping param not given
        input_params = {
            "year": "2000",
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
            "grouped_by": "month",
        }
        plugin._check_input_parameters_valid("PT1", input_params)
        expected_params = {
            "year": "2000",
            "month": ["01", "02", "03"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
        }
        self.assertDictEqual(expected_params, input_params)

    @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
    @mock.patch("eodag.plugins.search.cop_ghsl.requests.get")
    def test_plugins_search_cop_ghsl_get_tiles_for_filters(
        self, mock_requests_get, mock_fetch_constraints
    ):
        mock_fetch_constraints.return_value = {"constraints": self.constraints}
        provider_tiles = {
            "grid": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
            "unit": "lat/lon",
        }
        mock_requests_get.return_value = MockResponse(
            json_data=provider_tiles, status_code=200
        )
        collection = "GHS_BUILT_S"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        product_type_config = deepcopy(plugin.config.products.get(collection, {}))
        input_params = {
            "year": ["2000", "2005"],
            "proj:code": "EPSG:4326",
            "tile_size": "3ss",
            "collection": collection,
        }
        tiles, unit = plugin._get_tiles_for_filters(product_type_config, input_params)
        self.assertEqual("lat/lon", unit)
        self.assertEqual(2, len(tiles))
        self.assertIn("2000", tiles)
        self.assertIn("2005", tiles)
        self.assertEqual(mock_requests_get.call_count, 2)
        ssl_verify = getattr(plugin.config, "ssl_verify", True)
        mock_requests_get.assert_has_calls(
            [
                mock.call(
                    "https://human-settlement.emergency.copernicus.eu/data/tilesDLD/tilesDLD_BUILT_2000_3ss_4326.json",
                    verify=ssl_verify,
                    timeout=5,
                    headers=USER_AGENT,
                ),
                mock.call(
                    "https://human-settlement.emergency.copernicus.eu/data/tilesDLD/tilesDLD_BUILT_2005_3ss_4326.json",
                    verify=ssl_verify,
                    timeout=5,
                    headers=USER_AGENT,
                ),
            ]
        )

    @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
    @mock.patch("eodag.plugins.search.cop_ghsl.requests.get")
    def test_plugins_search_cop_ghsl_get_tile_from_product_id(
        self, mock_requests_get, mock_fetch_constraints
    ):
        """test fetching a tile for a product id"""
        mock_fetch_constraints.return_value = {"constraints": self.constraints}
        provider_tiles = {
            "grid": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
            "unit": "lat/lon",
        }
        mock_requests_get.return_value = MockResponse(
            json_data=provider_tiles, status_code=200
        )
        collection = "GHS_BUILT_S"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        params = {
            "collection": collection,
            "id": "GHS_BUILT_S__4326_3ss_2000_NRES__R3_C4",
        }
        tile, unit = plugin._get_tile_from_product_id(params)
        self.assertEqual("lat/lon", unit)
        self.assertEqual(1, len(tile))
        self.assertEqual(1, len(tile["2000"]))
        self.assertEqual("R3_C4", tile["2000"][0]["tileID"])

    def test_plugins_search_cop_ghsl_create_products_from_tiles(self):
        """test the creation of products based on tiles returned by the provider"""
        tiles = {
            "2000": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
            "2005": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
        }
        collection = "GHS_BUILT_S"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        product_type_config = deepcopy(plugin.config.products.get(collection, {}))
        params = product_type_config
        params["year"] = ["2000", "2005"]
        params["proj:code"] = "EPSG:4326"
        params["tile_size"] = "3ss"
        params["classification"] = "TOTAL"
        params["per_page"] = 5
        params["page"] = 1
        products, count = plugin._create_products_from_tiles(
            tiles, "lat/lon", collection, params, "classification", need_count=True
        )
        self.assertEqual(8, count)
        self.assertEqual(5, len(products))
        properties = products[0].properties
        self.assertEqual("2000-01-01T00:00:00.000Z", properties["start_datetime"])
        self.assertEqual("2000-12-31T23:59:59.000Z", properties["end_datetime"])
        self.assertEqual("2000", properties["year"])
        self.assertEqual("EPSG:4326", properties["proj:code"])
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_BUILT_S_GLOBE_R2023A/"
            "GHS_BUILT_S_E2000_GLOBE_R2023A_4326_3ss/V1-0/tiles/GHS_BUILT_S_E2000_GLOBE_R2023A_4326_3ss_V1_0_R3_C3.zip",
            properties["eodag:download_link"],
        )
        geometry = get_geometry_from_various(
            geometry=["-160.008", "69.100", "-150.008", "59.100"]
        )
        self.assertEqual(geometry, products[0].geometry)

    def test_plugins_search_cop_ghsl_create_products_from_tiles_modified_dataset(self):
        """test the creation of products based on tiles returned by the provider"""
        tiles = {
            "2015": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
        }
        collection = "GHS_ESM"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        product_type_config = deepcopy(plugin.config.products.get(collection, {}))
        params = product_type_config
        params["tile_size"] = "10m"
        params["per_page"] = 5
        params["page"] = 1
        products, count = plugin._create_products_from_tiles(
            tiles, "lat/lon", collection, params, need_count=True
        )
        self.assertEqual(4, count)
        self.assertEqual(4, len(products))
        properties = products[0].properties
        self.assertEqual("2015-01-01T00:00:00.000Z", properties["start_datetime"])
        self.assertEqual("2015-12-31T23:59:59.000Z", properties["end_datetime"])
        self.assertEqual("2015", properties["year"])
        self.assertEqual("EPSG:3035", properties["proj:code"])
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/ESM_BUILT_VHR2015_Europe_R2019/"
            "ESM_BUILT_VHR2015CLASS_EUROPE_R2019_3035_10/V1-0/tiles/R3_C3.zip",
            properties["eodag:download_link"],
        )
        geometry = get_geometry_from_various(
            geometry=["-160.008", "69.100", "-150.008", "59.100"]
        )
        self.assertEqual(geometry, products[0].geometry)

    def test_plugins_search_cop_ghsl_create_products_without_tiles(self):
        """test if products are created correctly for product types without tiles"""

        prep = PreparedSearch(limit=5)
        # product type with one file
        collection = "GHS_FUA"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        plugin.config.collection_config = {
            "collection": collection,
            "extent": {
                "temporal": {
                    "interval": [["2015-01-01T00:00:00Z", "2015-12-31T00:00:00Z"]]
                }
            },
        }
        products, count = plugin._create_products_without_tiles(collection, prep, {})
        self.assertEqual(1, count)
        self.assertEqual(1, len(products))
        properties = products[0].properties
        self.assertEqual("2015-01-01T00:00:00Z", properties["start_datetime"])
        self.assertEqual("2015-12-31T00:00:00Z", properties["end_datetime"])
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL//GHS_FUA_UCDB2015_GLOBE_R2019A"
            "/V1-0/GHS_FUA_UCDB2015_GLOBE_R2019A_54009_1K_V1_0.zip",
            properties["eodag:download_link"],
        )
        # product type with several files
        collection = "GHS_UCDB_REGION"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        plugin.config.collection_config = {
            "collection": collection,
            "extent": {
                "temporal": {
                    "interval": [["1975-01-01T00:00:00Z", "2030-12-31T00:00:00Z"]]
                }
            },
        }
        # with filter for region
        products, count = plugin._create_products_without_tiles(
            collection, prep, {"region": "EUROPE", "grouped_by": "region"}
        )
        self.assertEqual(1, count)
        self.assertEqual(1, len(products))
        properties = products[0].properties
        self.assertEqual("1975-01-01T00:00:00Z", properties["start_datetime"])
        self.assertEqual("2030-12-31T00:00:00Z", properties["end_datetime"])
        self.assertEqual("EUROPE", properties["region"])
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_UCDB_GLOBE_R2024A/GHS_UCDB_REGION_GLOBE_R2024A"
            "/GHS_UCDB_REGION_EUROPE_R2024A/V1-1/GHS_UCDB_REGION_EUROPE_R2024A_V1_1.zip",
            properties["eodag:download_link"],
        )

        # product type with several files and assets
        collection = "GHS_ENACT_POP"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        filters = {
            "grouped_by": "month",
            "month": "02",
            "tile_size": "30ss",
            "proj:code": "EPSG:4326",
        }
        product_type_mapping = deepcopy(plugin.config.products.get(collection, {}))
        filters.update(product_type_mapping)
        products, count = plugin._create_products_without_tiles(
            collection, prep, filters
        )
        self.assertEqual(1, count)
        self.assertEqual(1, len(products))
        properties = products[0].properties
        self.assertEqual("2011-02-01T00:00:00.000Z", properties["start_datetime"])
        self.assertEqual("2011-02-28T23:59:59.000Z", properties["end_datetime"])
        self.assertEqual("2011", properties["year"])
        self.assertEqual("02", properties["month"])
        assets = products[0].assets
        self.assertEqual(2, len(assets))
        self.assertIn("day", assets)
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/ENACT/ENACT_POP_2011_EU28_R2020A"
            "/ENACT_POP_D022011_EU28_R2020A_4326_30ss/V1-0/ENACT_POP_D022011_EU28_R2020A_4326_30ss_V1_0.zip",
            assets["day"]["href"],
        )
        self.assertIn("night", assets)

    @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
    def test_plugins_search_cop_ghsl_discover_queryables(self, mock_fetch_constraints):
        """test that the correct queryables are returned"""
        mock_fetch_constraints.return_value = {"constraints": self.constraints}
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection="GHS_BUILT_S", provider="cop_ghsl"
            )
        )
        kwargs = {"collection": "GHS_BUILT_S"}
        collection_config = plugin.config.products.get("GHS_BUILT_S", {})
        kwargs.update(collection_config)
        kwargs.pop("metadata_mapping")
        queryables = plugin.discover_queryables(**kwargs)
        self.assertEqual(6, len(queryables))
        expected_queryables = [
            "year",
            "tile_size",
            "proj:code",
            "start",
            "end",
            "geom",
        ]
        for qu in expected_queryables:
            self.assertIn(qu, queryables)
        tile_size_queryable = queryables["tile_size"]
        self.assertIn(Literal["10m", "3ss"], get_args(tile_size_queryable))

        # test filtering queryables
        kwargs["proj:code"] = "EPSG:54009"
        queryables = plugin.discover_queryables(**kwargs)
        tile_size_queryable = queryables["tile_size"]
        self.assertIn(Literal["10m"], get_args(tile_size_queryable))
        # with array
        kwargs["proj:code"] = ["EPSG:54009"]
        queryables = plugin.discover_queryables(**kwargs)
        tile_size_queryable = queryables["tile_size"]
        self.assertIn(Literal["10m"], get_args(tile_size_queryable))

        # product type without geometry filter
        constraints = deepcopy(self.constraints)
        for constraint in constraints:
            constraint["year"] = ["2011"]
            constraint["month"] = ["01", "02"]
        mock_fetch_constraints.return_value = {"constraints": constraints}
        kwargs = {"collection": "GHS_ENACT_POP"}
        collection_config = plugin.config.products.get("GHS_ENACT_POP", {})
        kwargs.update(collection_config)
        kwargs.pop("metadata_mapping")
        kwargs.pop("assets_mapping")
        queryables = plugin.discover_queryables(**kwargs)
        self.assertEqual(6, len(queryables))
        expected_queryables = [
            "year",
            "tile_size",
            "proj:code",
            "month",
            "start",
            "end",
        ]
        for qu in expected_queryables:
            self.assertIn(qu, queryables)


class TestSearchPluginEumetsatDsSearch(BaseSearchPluginTest):
    def test_plugins_search_eumetsatds_normalize(self):
        """Test the normalization of search results for eumetsat_ds provider"""
        with open(
            self.provider_resp_dir / "eumetsat_ds_search.json", encoding="utf-8"
        ) as f:
            raw_features = json.load(f)["features"]

        search_plugin = self.get_search_plugin(provider="eumetsat_ds")
        normalized = search_plugin.normalize_results(RawSearchResult(raw_features))

        self.assertTrue(len(normalized) > 0)
        base_href = (
            "https://api.eumetsat.int/data/download/1.0.0/collections/"
            "EO%3AEUM%3ADAT%3A0921/products/PREmm20201201000000120IMPGS01GL/"
            "entry?name="
        )
        self.assertDictEqual(
            dict(normalized[0].assets),
            {
                "EOPMetadata.xml": {
                    "href": base_href + "EOPMetadata.xml",
                    "title": "EOPMetadata.xml",
                    "roles": ["metadata"],
                    "eumesat_ds:type": "Link",
                    "type": "application/xml",
                },
                "manifest.xml": {
                    "href": base_href + "manifest.xml",
                    "title": "manifest.xml",
                    "roles": ["metadata"],
                    "eumesat_ds:type": "Link",
                    "type": "application/xml",
                },
                "nc": {
                    "href": base_href + "PREmm20201201000000120IMPGS01GL.nc",
                    "title": "nc",
                    "roles": ["data"],
                    "eumesat_ds:type": "Link",
                    "type": "application/x-netcdf",
                },
            },
        )
