# -*- 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 copy
import datetime as dt
import json
import logging
import os
import ssl
import sys
import tempfile
import unittest
import warnings
from contextlib import closing
from io import StringIO
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock

import requests
from click.exceptions import BadParameter
from dateutil import parser as dateutil_parser
from requests.exceptions import RequestException
from shapely.geometry import Point, Polygon

import eodag.utils as utils
from eodag.utils import (
    _build_float_range_cls,
    _deprecated_class,
    format_string,
    nested_pairs2dict,
)
from eodag.utils.exceptions import MisconfiguredError
from eodag.utils.logging import TqdmLoggingHandler
from tests.context import (
    HTTP_REQ_TIMEOUT,
    USER_AGENT,
    DownloadedCallback,
    LocalFileAdapter,
    NotebookWidgets,
    ProgressCallback,
    RequestError,
    TimeOutError,
    check_ipython,
    check_notebook,
    deepcopy,
    fetch_json,
    flatten_top_directories,
    get_bucket_name_and_prefix,
    get_geometry_from_ecmwf_area,
    get_geometry_from_ecmwf_feature,
    get_ssl_context,
    get_timestamp,
    import_all_modules,
    is_env_var_true,
    merge_mappings,
    patch_owslib_requests,
    path_to_uri,
    setup_logging,
    uri_to_path,
)


class TestUtils(unittest.TestCase):
    """Unit tests for utility functions in eodag.utils."""

    def setUp(self) -> None:
        """Set up logging before each test."""
        super(TestUtils, self).setUp()
        setup_logging(verbose=1)

    def tearDown(self) -> None:
        """Reset logging after each test."""
        super(TestUtils, self).tearDown()
        # reset logging
        logger = logging.getLogger("eodag")
        logger.handlers = []
        logger.level = 0

    def test_build_float_range_cls(self):
        """Test FloatRange conversion and range validation."""
        float_range = _build_float_range_cls()
        parameter_type = float_range(0, 100)

        self.assertEqual(parameter_type.convert("42.5", None, None), 42.5)
        with self.assertRaises(BadParameter):
            parameter_type.convert("-1", None, None)
        with self.assertRaises(BadParameter):
            parameter_type.convert("101", None, None)

        self.assertEqual(float_range(max=100).convert(42, None, None), 42.0)
        self.assertEqual(float_range(min=0).convert(42, None, None), 42.0)

    def test_utils_getattr_float_range(self):
        """Test __getattr__ lazily creates and caches FloatRange."""
        previous_float_range = utils.__dict__.pop("FloatRange", None)
        try:
            float_range = getattr(utils, "FloatRange")
            self.assertIs(getattr(utils, "FloatRange"), float_range)
            self.assertEqual(float_range(0, 1).convert("0.5", None, None), 0.5)
            with self.assertRaises(AttributeError):
                getattr(utils, "missing_attribute")
        finally:
            utils.__dict__.pop("FloatRange", None)
            if previous_float_range is not None:
                utils.__dict__["FloatRange"] = previous_float_range

    def test_deprecated_class(self):
        """Test _deprecated_class preserves identity and warns on constructors."""

        class Example:
            def __init__(self, value):
                self.value = value

            @classmethod
            def model_validate(cls, value):
                return cls(value)

        decorated_class = _deprecated_class(reason="legacy", version="3.0")(Example)
        self.assertIs(decorated_class, Example)

        with warnings.catch_warnings(record=True) as caught_warnings:
            warnings.simplefilter("always")
            instance = Example("value")
        self.assertEqual(instance.value, "value")
        self.assertEqual(len(caught_warnings), 1)
        self.assertIn(
            "Example (legacy) -- Deprecated since v3.0", str(caught_warnings[0].message)
        )

        with warnings.catch_warnings(record=True) as caught_warnings:
            warnings.simplefilter("always")
            validated = Example.model_validate("validated")
        self.assertEqual(validated.value, "validated")
        self.assertEqual(len(caught_warnings), 2)

    def test_format_string_exception_handling(self):
        """Test format_string handles malformed and colon-containing formats."""
        with self.assertRaisesRegex(MisconfiguredError, "Unable to format"):
            format_string(None, "{invalid", value="unused")

        self.assertEqual(
            format_string(None, "{foo:bar}", **{"foo:bar": "value"}),
            "value",
        )

    def test_nested_pairs2dict_value_error(self):
        """Test nested_pairs2dict returns malformed pairs unchanged."""
        pairs = [["valid", "pair"], ["invalid"]]

        self.assertIs(nested_pairs2dict(pairs), pairs)

    def test_utils_get_timestamp(self):
        """Test get_timestamp returns correct UNIX timestamp for various date formats"""
        # Date to timestamp to date, this assumes the date is in UTC
        requested_date = "2020-08-08"  # Considered as 2020-08-08T00:00:00Z
        ts_in_secs = get_timestamp(requested_date)
        expected_dt = dateutil_parser.parse(requested_date).replace(
            tzinfo=dt.timezone.utc
        )
        actual_utc_dt = dt.datetime.fromtimestamp(ts_in_secs, dt.timezone.utc)
        self.assertEqual(actual_utc_dt, expected_dt)

        # Handle UTC datetime
        self.assertEqual(get_timestamp("2021-04-21T18:27:19.123Z"), 1619029639.123)
        # If date/datetime not in UTC, it assumes it's in UTC
        self.assertEqual(
            get_timestamp("2021-04-21T18:27:19.123"),
            get_timestamp("2021-04-21T18:27:19.123Z"),
        )
        self.assertEqual(
            get_timestamp("2021-04-21"), get_timestamp("2021-04-21T00:00:00.000Z")
        )

        # Non UTC datetime are also supported
        self.assertEqual(get_timestamp("2021-04-21T00:00:00+02:00"), 1618956000)

    def test_is_env_var_true(self):
        """Test is_env_var_true correctly interprets environment variable values."""
        test_cases = {
            "true": True,
            "True": True,
            "yes": True,
            "on": True,
            "1": True,
            "false": False,
            "False": False,
            "0": False,
            "no": False,
            "": False,
        }
        for value, expected in test_cases.items():
            with mock.patch.dict(os.environ, {"TEST_ENV_VAR": value}):
                self.assertEqual(is_env_var_true("TEST_ENV_VAR"), expected)

    def test_uri_to_path(self):
        """Test uri_to_path converts file URIs to file system paths."""
        if sys.platform == "win32":
            expected_path = r"C:\tmp\file.txt"
            tested_uri = r"file:///C:/tmp/file.txt"
            other_tested_uri = r"file:/C:/tmp/file.txt"
        else:
            expected_path = "/tmp/file.txt"
            tested_uri = "file:///tmp/file.txt"
            other_tested_uri = "file:/tmp/file.txt"
        actual_path = uri_to_path(tested_uri)
        self.assertEqual(actual_path, expected_path)
        actual_path = uri_to_path(other_tested_uri)
        self.assertEqual(actual_path, expected_path)
        with self.assertRaises(ValueError):
            uri_to_path("not_a_uri")

    def test_ssl_context(self):
        """Test get_ssl_context returns SSL context with correct settings."""

        ssl_ctx = get_ssl_context(False)
        self.assertEqual(ssl_ctx.verify_mode, ssl.CERT_NONE)
        self.assertEqual(ssl_ctx.check_hostname, False)

        ssl_ctx = get_ssl_context(True)
        self.assertEqual(ssl_ctx.verify_mode, ssl.CERT_REQUIRED)
        self.assertEqual(ssl_ctx.check_hostname, True)

    def test_path_to_uri(self):
        """Test path_to_uri converts file system paths to file URIs."""
        if sys.platform == "win32":
            self.assertEqual(path_to_uri(r"C:\tmp\file.txt"), "file:///C:/tmp/file.txt")
        else:
            self.assertEqual(path_to_uri("/tmp/file.txt"), "file:///tmp/file.txt")

    def test_downloaded_callback(self):
        """Test DownloadedCallback is callable and handles product parameter"""
        downloaded_callback = DownloadedCallback()
        self.assertTrue(callable(downloaded_callback))
        try:
            downloaded_callback(product=None)
        except TypeError as e:
            self.fail(f"DownloadedCallback got an error when called: {e}")

    def test_progresscallback_init(self):
        """Test ProgressCallback can be instantiated with default values"""
        with ProgressCallback() as bar:
            self.assertEqual(bar.unit, "B")
            self.assertEqual(bar.unit_scale, True)
            self.assertEqual(bar.desc, "")

    def test_progresscallback_init_customize(self):
        """Test ProgressCallback can be instantiated with custom values"""
        with ProgressCallback(unit="foo", unit_scale=False, desc="bar", total=5) as bar:
            self.assertEqual(bar.unit, "foo")
            self.assertEqual(bar.unit_scale, False)
            self.assertEqual(bar.desc, "bar")
            self.assertEqual(bar.total, 5)

    def test_progresscallback_copy(self):
        """Test ProgressCallback can be copied with the same attributes"""
        with ProgressCallback(unit="foo", unit_scale=False, desc="bar", total=5) as bar:
            with bar.copy() as another_bar:
                self.assertEqual(another_bar.unit, "foo")
                self.assertEqual(another_bar.unit_scale, False)
                self.assertEqual(another_bar.desc, "bar")
                self.assertEqual(another_bar.total, 5)

    def test_progresscallback_disable(self):
        """Test ProgressCallback can be disabled via logging or parameter"""
        # enabled
        with closing(StringIO()) as tqdm_out:
            with ProgressCallback(total=2, file=tqdm_out) as bar:
                bar(1)
            self.assertIn("50%", tqdm_out.getvalue())

        # disabled using setup_logging
        setup_logging(verbose=1, no_progress_bar=True)
        with closing(StringIO()) as tqdm_out:
            with ProgressCallback(total=2, file=tqdm_out) as bar:
                bar(1)
            self.assertEqual(tqdm_out.getvalue(), "")
        setup_logging(verbose=1, no_progress_bar=False)

        # disabled using tqdm parameter
        with closing(StringIO()) as tqdm_out:
            with ProgressCallback(total=2, file=tqdm_out, disable=True) as bar:
                bar(1)
            self.assertEqual(tqdm_out.getvalue(), "")

    @mock.patch("tqdm.auto.tqdm.write")
    def test_tqdm_logging_handler_uses_tqdm_write(self, tqdm_write):
        """Test logging through tqdm preserves the progress bar output."""
        handler = TqdmLoggingHandler(stream=StringIO())
        handler.setFormatter(logging.Formatter("%(message)s"))
        record = logging.LogRecord(
            name="eodag",
            level=logging.INFO,
            pathname=__file__,
            lineno=1,
            msg="log message",
            args=(),
            exc_info=None,
        )

        handler.emit(record)

        tqdm_write.assert_called_once_with(
            "log message", file=handler.stream, end=handler.terminator
        )

    @mock.patch("tqdm.auto.tqdm.write")
    def test_tqdm_logging_handler_falls_back_to_stream(self, tqdm_write):
        """Test disabled progress bars use the regular stream handler."""
        setup_logging(verbose=2, no_progress_bar=True)
        output = StringIO()
        handler = TqdmLoggingHandler(stream=output)
        handler.setFormatter(logging.Formatter("%(message)s"))
        record = logging.LogRecord(
            name="eodag",
            level=logging.INFO,
            pathname=__file__,
            lineno=1,
            msg="log message",
            args=(),
            exc_info=None,
        )

        handler.emit(record)

        self.assertEqual(output.getvalue(), "log message\n")
        tqdm_write.assert_not_called()

    def test_setup_logging_installs_tqdm_handler(self):
        """Test console logging uses the handler that cooperates with tqdm."""
        setup_logging(verbose=2)

        logger = logging.getLogger("eodag")
        self.assertIsInstance(logger.handlers[0], TqdmLoggingHandler)

    def test_merge_mappings(self):
        """Test merge_mappings merges configuration mappings correctly."""

        # nested dict
        mapping = {"foo": {"keyA": "obsolete"}}
        merge_mappings(mapping, {"foo": {"keya": "new"}})
        self.assertEqual(mapping, {"foo": {"keyA": "new"}})

        # list replaced by string
        mapping = {"keyA": ["obsolete1", "obsolete2"]}
        merge_mappings(mapping, {"keya": "new"})
        self.assertEqual(mapping, {"keyA": "new"})

        # string replaced by list
        mapping = {"keyA": "obsolete"}
        merge_mappings(mapping, {"keya": ["new1", "new2"]})
        self.assertEqual(mapping, {"keyA": ["new1", "new2"]})

        # bool replaced by str
        mapping = {"keyA": True}
        merge_mappings(mapping, {"keya": "fAlSe"})
        self.assertEqual(mapping, {"keyA": False})

        # int replaced by str
        mapping = {"keyA": 1}
        merge_mappings(mapping, {"keya": "2"})
        self.assertEqual(mapping, {"keyA": 2})

        # override ignored if cast cannot be perfomed to origin type
        mapping = {"keyA": True}
        merge_mappings(mapping, {"keya": "bar"})
        self.assertEqual(mapping, {"keyA": True})

    def test_get_bucket_name_and_prefix(self):
        """Test get_bucket_name_and_prefix extracts bucket and prefix from URLs"""
        self.assertEqual(
            get_bucket_name_and_prefix(
                "s3://sentinel-s2-l1c/tiles/50/R/LR/2021/6/8/0/B02.jp2"
            ),
            ("sentinel-s2-l1c", "tiles/50/R/LR/2021/6/8/0/B02.jp2"),
        )
        self.assertEqual(
            get_bucket_name_and_prefix(
                "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/44/2022/10/S2B_20221011/B01.tif"
            ),
            (
                "sentinel-cogs",
                "sentinel-s2-l2a-cogs/44/2022/10/S2B_20221011/B01.tif",
            ),
        )
        self.assertEqual(
            get_bucket_name_and_prefix(
                "https://s3.us-west-2.amazonaws.com/sentinel-cogs/sentinel-s2-l2a-cogs/44/2022/10/S2B_20221011/B01.tif"
            ),
            (
                "sentinel-cogs",
                "sentinel-s2-l2a-cogs/44/2022/10/S2B_20221011/B01.tif",
            ),
        )
        self.assertEqual(
            get_bucket_name_and_prefix(
                "https://obs.eu-de.otc.t-systems.com/s2-l1c-2021-q4/30/T/2021/11/14/S2A_MSIL1C_20211114T110321_N0301",
                0,
            ),
            ("s2-l1c-2021-q4", "30/T/2021/11/14/S2A_MSIL1C_20211114T110321_N0301"),
        )
        self.assertEqual(
            get_bucket_name_and_prefix(
                "products/2021/1/5/S2A_MSIL1C_20210105T105441_N0209_R051_T30TYN_20210105T130129",
            ),
            (
                None,
                "products/2021/1/5/S2A_MSIL1C_20210105T105441_N0209_R051_T30TYN_20210105T130129",
            ),
        )
        self.assertEqual(
            get_bucket_name_and_prefix(
                "bucket/path/to/product",
                0,
            ),
            ("bucket", "path/to/product"),
        )
        self.assertEqual(
            get_bucket_name_and_prefix(
                "http://foo/bar/bucket/path/to/product",
                1,
            ),
            ("bucket", "path/to/product"),
        )

    def test_flatten_top_dirs(self):
        """Test flatten_top_directories flattens nested directory structures"""
        with TemporaryDirectory() as nested_dir_root:
            os.makedirs(os.path.join(nested_dir_root, "a", "b", "c1"))
            os.makedirs(os.path.join(nested_dir_root, "a", "b", "c2"))
            # create empty files
            open(os.path.join(nested_dir_root, "a", "b", "c1", "foo"), "a").close()
            open(os.path.join(nested_dir_root, "a", "b", "c2", "bar"), "a").close()
            open(os.path.join(nested_dir_root, "a", "b", "c2", "baz"), "a").close()

            flatten_top_directories(nested_dir_root)

            dir_content = list(Path(nested_dir_root).glob("**/*"))

            self.assertEqual(len(dir_content), 5)
            self.assertIn(Path(nested_dir_root) / "c1", dir_content)
            self.assertIn(Path(nested_dir_root) / "c1" / "foo", dir_content)
            self.assertIn(Path(nested_dir_root) / "c2", dir_content)
            self.assertIn(Path(nested_dir_root) / "c2" / "bar", dir_content)
            self.assertIn(Path(nested_dir_root) / "c2" / "baz", dir_content)

    def test_flatten_top_dirs_single_file(self):
        """Test flatten_top_directories flattens structure with a single file"""
        with TemporaryDirectory() as nested_dir_root:
            os.makedirs(os.path.join(nested_dir_root, "a", "b", "c1"))
            # create empty file
            open(os.path.join(nested_dir_root, "a", "b", "c1", "foo"), "a").close()

            flatten_top_directories(nested_dir_root)

            dir_content = list(Path(nested_dir_root).glob("**/*"))

            self.assertEqual(len(dir_content), 1)
            self.assertIn(Path(nested_dir_root) / "foo", dir_content)

    def test_flatten_top_dirs_given_subdir(self):
        """Test flatten_top_directories flattens structure from a given subdirectory"""
        with TemporaryDirectory() as nested_dir_root:
            os.makedirs(os.path.join(nested_dir_root, "a", "b", "c1"))
            os.makedirs(os.path.join(nested_dir_root, "a", "b", "c2"))
            # create empty files
            open(os.path.join(nested_dir_root, "a", "b", "c1", "foo"), "a").close()
            open(os.path.join(nested_dir_root, "a", "b", "c2", "bar"), "a").close()
            open(os.path.join(nested_dir_root, "a", "b", "c2", "baz"), "a").close()

            flatten_top_directories(nested_dir_root, os.path.join(nested_dir_root, "a"))

            dir_content = list(Path(nested_dir_root).glob("**/*"))

            self.assertEqual(len(dir_content), 6)
            self.assertIn(Path(nested_dir_root) / "b", dir_content)
            self.assertIn(Path(nested_dir_root) / "b" / "c1", dir_content)
            self.assertIn(Path(nested_dir_root) / "b" / "c1" / "foo", dir_content)
            self.assertIn(Path(nested_dir_root) / "b" / "c2", dir_content)
            self.assertIn(Path(nested_dir_root) / "b" / "c2" / "bar", dir_content)
            self.assertIn(Path(nested_dir_root) / "b" / "c2" / "baz", dir_content)

    def test_deepcopy(self):
        """Test deepcopy performs a recursive copy of objects"""
        original = {"a": [{"b": [0, 1, 2]}]}
        shallow_copied = copy.copy(original)
        deep_copied = deepcopy(original)
        # change original
        original["a"][0]["b"][0] = 5
        # shallow copy also changed
        self.assertEqual(shallow_copied["a"][0]["b"][0], 5)
        # deep copy did not change
        self.assertEqual(deep_copied["a"][0]["b"][0], 0)

    def test_fetch_json(self):
        """Test fetch_json fetches and parses JSON from a URL, handles errors"""

        # distant
        file_url = "https://foo.bar"
        with unittest.mock.patch(
            "eodag.utils.requests.requests.sessions.Session.get",
            autospec=True,
        ) as mock_get:
            mock_get.return_value = unittest.mock.Mock()
            mock_get.return_value.json.return_value = {"foo": "bar"}
            file_content = fetch_json(file_url)
            self.assertEqual(file_content["foo"], "bar")
            mock_get.assert_called_once_with(
                mock.ANY,
                file_url,
                headers=USER_AGENT,
                auth=None,
                timeout=HTTP_REQ_TIMEOUT,
            )

        # distant error
        with unittest.mock.patch(
            "eodag.utils.requests.requests.sessions.Session.get",
            autospec=True,
            side_effect=RequestException,
        ) as mock_get:
            self.assertRaises(RequestError, fetch_json, file_url)

    def test_get_geometry_from_ecmwf_feature_extended_types(self):
        """ECMWF feature types are converted to the expected Shapely geometries."""
        geom = get_geometry_from_ecmwf_feature(
            {
                "type": "boundingbox",
                "points": [[44.0, 1.0, 500], [43.0, 2.0, 1000]],
                "axes": ["latitude", "longitude", "levelist"],
            }
        )
        self.assertIsInstance(geom, Polygon)
        self.assertEqual(geom.bounds, (1.0, 43.0, 2.0, 44.0))

        geom = get_geometry_from_ecmwf_feature(
            {"type": "position", "points": [[43.5, 1.5]]}
        )
        self.assertIsInstance(geom, Point)
        self.assertEqual((geom.x, geom.y), (1.5, 43.5))

        geom = get_geometry_from_ecmwf_feature(
            {
                "type": "timeseries",
                "points": [[43.5, 1.5]],
                "axes": ["latitude", "longitude"],
                "time_axis": "step",
            }
        )
        self.assertIsInstance(geom, Point)

        with self.assertRaises(TypeError):
            get_geometry_from_ecmwf_feature(
                {
                    "type": "timeseries",
                    "points": [[43.5, 1.5]],
                    "axes": ["latitude", "longitude"],
                }
            )

        geom = get_geometry_from_ecmwf_feature(
            {"type": "verticalprofile", "points": [[43.5, 1.5]], "axes": "levelist"}
        )
        self.assertIsInstance(geom, Point)

        geom = get_geometry_from_ecmwf_feature(
            {
                "type": "trajectory",
                "points": [[43.0, 1.0], [43.5, 1.5], [44.0, 2.0]],
                "axes": ["latitude", "longitude"],
                "inflation": 1.0,
            }
        )
        self.assertEqual(list(geom.coords), [(1.0, 43.0), (1.5, 43.5), (2.0, 44.0)])

        self.assertIsNone(
            get_geometry_from_ecmwf_feature(
                {"type": "circle", "center": [43.5, 1.5], "radius": 1.0}
            )
        )

    def test_get_geometry_from_ecmwf_feature_exceptions(self):
        """ECMWF feature validation raises TypeError for invalid geometries."""
        invalid_geometries = [
            None,
            {},
            {"type": "polygon"},
            {"type": "polygon", "shape": "invalid"},
            {"type": "boundingbox"},
            {"type": "boundingbox", "points": "invalid"},
            {"type": "position"},
            {"type": "position", "points": []},
            {"type": "trajectory"},
            {"type": "trajectory", "points": [[43.0, 1.0]]},
            {
                "type": "trajectory",
                "points": [[43.0, 1.0], [43.5, 1.5]],
            },
            {"type": "circle"},
            {"type": "circle", "center": [43.5, 1.5]},
            {"type": "unsupported"},
        ]

        for geometry in invalid_geometries:
            with self.subTest(geometry=geometry):
                with self.assertRaises(TypeError):
                    get_geometry_from_ecmwf_feature(geometry)

    def test_get_geometry_from_ecmwf_area_accepts_list_and_string(self):
        """``get_geometry_from_ecmwf_area`` must accept both list and slash-separated string formats."""
        # list format: [max_lat, min_lon, min_lat, max_lon]
        geom_from_list = get_geometry_from_ecmwf_area([44.0, 1.0, 43.0, 2.0])
        self.assertIsInstance(geom_from_list, Polygon)
        self.assertEqual(geom_from_list.bounds, (1.0, 43.0, 2.0, 44.0))

        # equivalent string format: "max_lat/min_lon/min_lat/max_lon"
        geom_from_str = get_geometry_from_ecmwf_area("44.0/1.0/43.0/2.0")
        self.assertIsInstance(geom_from_str, Polygon)
        self.assertEqual(geom_from_str.bounds, (1.0, 43.0, 2.0, 44.0))

        # both formats should produce equivalent geometries
        self.assertTrue(geom_from_list.equals(geom_from_str))

        # invalid string: wrong number of components
        with self.assertRaises(ValueError):
            get_geometry_from_ecmwf_area("44.0/1.0/43.0")

        # invalid string: non-numeric content
        with self.assertRaises(ValueError):
            get_geometry_from_ecmwf_area("a/b/c/d")

    def test_patch_owslib_requests_restores_functions(self):
        """OWSLib request patches apply verification and restore originals."""
        import owslib.util

        original_request = owslib.util.requests.request
        original_post = owslib.util.requests.post
        with patch_owslib_requests(verify=False):
            self.assertFalse(owslib.util.requests.request.keywords["verify"])
            self.assertFalse(owslib.util.requests.post.keywords["verify"])
        self.assertIs(owslib.util.requests.request, original_request)
        self.assertIs(owslib.util.requests.post, original_post)
        with self.assertRaisesRegex(RuntimeError, "boom"):
            with patch_owslib_requests():
                raise RuntimeError("boom")
        self.assertIs(owslib.util.requests.request, original_request)

    def test_import_all_modules_honors_exclude(self):
        """Module discovery skips excluded entries."""
        from types import SimpleNamespace

        package = SimpleNamespace(__name__="test_package", __path__=["unused"])
        modules = [
            (None, "module", False),
            (None, "subpackage", True),
            (None, "excluded", False),
        ]
        with (
            mock.patch(
                "eodag.utils.import_system.pkgutil.iter_modules", return_value=modules
            ),
            mock.patch(
                "eodag.utils.import_system.importlib.import_module"
            ) as import_module,
        ):
            import_all_modules(package, depth=1, exclude=("excluded",))
        import_module.assert_called_once_with(".module", package="test_package")

    def test_notebook_detection_and_non_notebook_widgets(self):
        """Notebook widgets are no-ops outside a notebook."""
        self.assertFalse(check_ipython())
        self.assertFalse(check_notebook())
        widgets = NotebookWidgets()
        self.assertIsNone(widgets.display_html("ignored"))
        self.assertIsNone(widgets.clear_html())

    def test_notebook_detection_shells(self):
        """Notebook detection distinguishes Jupyter and terminal IPython shells."""
        with mock.patch("eodag.utils.notebook.get_ipython", create=True) as get_ipython:
            get_ipython.return_value.__class__.__name__ = "ZMQInteractiveShell"
            self.assertTrue(check_notebook())
            get_ipython.return_value.__class__.__name__ = "TerminalInteractiveShell"
            self.assertFalse(check_notebook())

    def test_local_file_adapter_statuses_and_fetch_json(self):
        """Local file requests return expected statuses and parse JSON."""
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".json", delete=False
        ) as file:
            json.dump({"value": 1}, file)
            path = file.name
        try:
            self.assertEqual(fetch_json(path), {"value": 1})
            self.assertEqual(LocalFileAdapter._chkpath("put", path)[0], 501)
            self.assertEqual(LocalFileAdapter._chkpath("patch", path)[0], 405)
            self.assertEqual(LocalFileAdapter._chkpath("get", path)[0], 200)
            self.assertEqual(
                LocalFileAdapter._chkpath("get", path + "-missing")[0], 404
            )
            self.assertEqual(
                LocalFileAdapter._chkpath("get", os.path.dirname(path))[0], 400
            )
        finally:
            os.unlink(path)

    @mock.patch("eodag.utils.requests.requests.sessions.Session.get", autospec=True)
    def test_fetch_json_timeout_and_request_error(self, mock_get):
        """fetch_json translates timeout and request failures to EODAG errors."""
        mock_get.side_effect = requests.exceptions.Timeout()
        with self.assertRaises(TimeOutError):
            fetch_json("https://example.test")
        mock_get.side_effect = requests.exceptions.RequestException()
        with self.assertRaises(RequestError):
            fetch_json("https://example.test")
