# Copyright 2016 Camptocamp SA
# Copyright 2015 Odoo
# Copyright 2025 Holger Nahrstaedt
# @author Pierre Verkest <pierre@verkest.fr>
# @author Holger Nahrstaedt <holger.nahrstaedt@hasomed.de>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)


import ast
import os
import signal
import subprocess
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Optional
from unittest import TestCase as UnitTestTestCase
from unittest import mock

import _pytest
import _pytest.python
import odoo
import pytest


def pytest_addoption(parser):
    parser.addoption(
        "--odoo-log-level",
        action="store",
        default="critical",
        help="Log-level used by the Odoo process during tests",
    )
    parser.addoption(
        "--oduit-env", action="store", help="Path of the Odoo configuration file"
    )
    parser.addoption(
        "--odoo-install",
        action="store",
        help="Define modules which should be installed.",
    )


def _has_oduit_config():
    """Check if .oduit.toml file exists using oduit config loader."""
    from oduit.config_loader import ConfigLoader

    return ConfigLoader().has_local_config()


def _build_odoo_config_with_oduit_core(config):
    """Build Odoo configuration using oduit builders."""

    from oduit.builders import ConfigProvider
    from oduit.config_loader import ConfigLoader

    # Start with configuration from .oduit.toml if it exists
    config_dict = {}
    config_loader = ConfigLoader()
    try:
        if config.getoption("--oduit-env"):
            config = config_loader.load_config(config.getoption("--oduit-env"))
        else:
            config = config_loader.load_local_config()
        config_dict.update(config)
    except Exception as e:
        # If .oduit.toml exists but can't be loaded, warn but continue
        import warnings

        warnings.warn(f"Failed to load .oduit.toml: {e}", stacklevel=2)
    # Create ConfigProvider and builder
    config_provider = ConfigProvider(config_dict)

    # Build options list by extracting relevant configuration
    return config_provider.get_odoo_params_list(
        ["config_file", "data_dir", "http_port", "gevent_port"],
        replace_underscore=False,
    )


@pytest.hookimpl(hookwrapper=True)
def pytest_cmdline_main(config):
    if _has_oduit_config() or config.getoption("--oduit-env"):
        # Use oduit builders for command line construction
        options = _build_odoo_config_with_oduit_core(config)
        value = config.getoption("--odoo-log-level")
        if value:
            options.append(f"--log-level={value}")

        value_install = config.getoption("--odoo-install")
        if value_install:
            options.append(f"--init={value_install}")
        odoo.tools.config.parse_config(options)

        config = odoo.tools.config  # type: ignore

        # Set up database preloading
        preload = []
        if config["db_name"]:  # type: ignore
            preload = config["db_name"].split(",")  # type: ignore
            for db_name in preload:
                try:
                    odoo.service.db._create_empty_database(db_name)
                    config["init"]["base"] = True  # type: ignore
                except odoo.service.db.DatabaseExists:
                    pass
                except Exception as err:
                    raise err

        support_subtest()
        disable_odoo_test_retry()
        monkey_patch_resolve_pkg_root_and_module_name()
        odoo.service.server.start(preload=preload, stop=True)
        # odoo.service.server.start() modifies the SIGINT signal by its own
        # one which in fact prevents us to stop anthem with Ctrl-c.
        # Restore the default one.
        signal.signal(signal.SIGINT, signal.default_int_handler)

        if odoo.release.version_info >= (18,):
            odoo.modules.module.current_test = True

        if odoo.release.version_info < (15,):
            # Refactor in Odoo 15, not needed anymore
            with odoo.api.Environment.manage():
                yield
        else:
            yield
    else:
        yield


@contextmanager
def _shared_filestore(original_db_name, db_name):
    # This method ensure that if tests are ran in a distributed way
    # we share the filestore between the original database and the
    # copy of the database. This is useful to avoid copying the
    # filestore for each worker.
    # This is done by patching the filestore method of the odoo
    # configuration to point to the original filestore.
    if original_db_name == db_name:
        yield
        return
    with mock.patch.object(odoo.tools.config, "filestore") as filestore:
        fs_path = os.path.join(
            odoo.tools.config["data_dir"], "filestore", original_db_name
        )
        filestore.return_value = fs_path
        yield


@contextmanager
def _worker_db_name():
    # This method ensure that if tests are ran in a distributed way
    # thanks to the use of pytest-xdist addon, each worker will use
    # a specific copy of the initial database to run their tests.
    # In this way we prevent deadlock errors.
    xdist_worker = os.getenv("PYTEST_XDIST_WORKER")
    original_db_name = db_name = odoo.tests.common.get_db_name()
    try:
        if xdist_worker:
            db_name = f"{original_db_name}-{xdist_worker}"
            subprocess.run(["dropdb", db_name, "--if-exists"], check=True)
            subprocess.run(["createdb", "-T", original_db_name, db_name], check=True)
            odoo.tools.config["db_name"] = db_name
            odoo.tools.config["dbfilter"] = f"^{db_name}$"
        with _shared_filestore(original_db_name, db_name):
            yield db_name
    finally:
        if db_name != original_db_name:
            odoo.sql_db.close_db(db_name)
            subprocess.run(["dropdb", db_name, "--if-exists"], check=True)
            odoo.tools.config["db_name"] = original_db_name
            odoo.tools.config["dbfilter"] = f"^{original_db_name}$"


@pytest.fixture(scope="session", autouse=True)
def load_registry():
    # Initialize the registry before running tests.
    # If we don't do that, the modules will be loaded *inside* of the first
    # test we run, which would trigger the launch of the postinstall tests
    # (because we force 'test_enable' to True and the at end of the loading of
    # the registry, the postinstall tests are run when test_enable is enabled).
    # And also give wrong timing indications.
    # Finally we enable `testing` flag on current thread
    # since Odoo sets it when loading test suites.
    threading.current_thread().testing = True
    with _worker_db_name() as db_name:
        odoo.modules.registry.Registry(db_name)
        yield


@pytest.fixture(scope="module", autouse=True)
def enable_odoo_test_flag():
    # When we run tests through Odoo, test_enable is always activated, and some
    # code might rely on this (for instance to selectively disable database
    # commits). When we run the tests through pytest, the flag is not
    # activated, and if it was activated globally, it would make odoo start all
    # tests in addition to the tests we are running through pytest.  If we
    # enable the option only in the scope of the tests modules, we won't
    # interfere with the odoo's loading of modules, thus we are good.
    odoo.tools.config["test_enable"] = True
    yield
    odoo.tools.config["test_enable"] = False


def monkey_patch_resolve_pkg_root_and_module_name():
    original_resolve_pkg_root_and_module_name = (
        _pytest.pathlib.resolve_pkg_root_and_module_name
    )

    def resolve_pkg_root_and_module_name(
        path: Path, *, consider_namespace_packages: bool = False
    ) -> "tuple[Path, str]":
        pkg_root, module_name = original_resolve_pkg_root_and_module_name(
            path, consider_namespace_packages=consider_namespace_packages
        )
        if not module_name.startswith("odoo.addons"):
            manifest = _find_manifest_path(path)
            if manifest and manifest.parent.name == module_name.split(".", 1)[0]:
                module_name = "odoo.addons." + module_name
        return pkg_root, module_name

    _pytest.pathlib.resolve_pkg_root_and_module_name = resolve_pkg_root_and_module_name


def support_subtest():
    """Odoo from version 16.0 re-define its own TestCase.subTest context manager
    Odoo assume the usage of OdooTestResult which is not our case
    using with pytest-odoo. So this fallback to the unitest.TestCase.subTest
    Context manager
    """
    try:
        from odoo.tests.case import TestCase

        TestCase.subTest = UnitTestTestCase.subTest
        TestCase.run = UnitTestTestCase.run
    except ImportError:
        # Odoo <= 15.0
        pass


def disable_odoo_test_retry():
    """Odoo BaseCase.run method overload TestCase.run and manage
    a retry mechanism that breaks using pytest launcher.
    Using `pytest-rerunfailures` we can use `--reruns` parameters
    if needs equivalent feature, so we remove such overload here.
    """
    try:
        from odoo.tests import BaseCase

        del BaseCase.run
    except (ImportError, AttributeError):
        # Odoo <= 15.0
        pass


def _find_manifest_path(collection_path: Path) -> Path:
    """Try to locate an Odoo manifest file in the collection path."""
    # check if collection_path is an addon directory
    path = collection_path
    for _ in range(5):
        if (path.parent / "__manifest__.py").is_file():
            break
        path = path.parent
    else:
        return None
    return path.parent / "__manifest__.py"


def pytest_ignore_collect(collection_path: Path) -> Optional[bool]:
    """Do not collect tests of modules that are marked non installable."""
    manifest_path = _find_manifest_path(collection_path)
    if not manifest_path:
        return None
    manifest = ast.literal_eval(manifest_path.read_text())
    if not manifest.get("installable", True):
        # installable = False, do not collect this
        return True
    return None
