"""
Unit tests for the unified paper downloader functionality.

These tests drive coverage through the public surface (factory .create(),
tool wrappers, and the tool entry) to exercise the key branches in
aiagents4pharma/talk2scholars/tools/paper_download/paper_downloader.py:
- Service selection in create()
- Hydra config load with/without cache, and failure path
- GlobalHydra clear when already initialized
- Service config extraction via OmegaConf, __dict__, items(), and dir() fallback
- _apply_config warning path (handled via public create())
- Success and both error paths in _download_papers_impl()
"""

import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch

from langchain_core.messages import ToolMessage
from langgraph.types import Command

from aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader import (
    PaperDownloaderFactory,
    _download_papers_impl,
    download_arxiv_papers,
    download_biorxiv_papers,
    download_medrxiv_papers,
    download_papers,
    download_pubmed_papers,
)


# --- tiny helpers to manipulate factory state without protected-access lint ---
def _set_cached_config(value):
    """set cached config in the factory for testing purposes."""
    setattr(PaperDownloaderFactory, "_cached_config", value)


def _set_config_lock(lock_obj):
    """set the config lock object in the factory for testing purposes."""
    setattr(PaperDownloaderFactory, "_config_lock", lock_obj)


class PaperDownloaderFactoryTestShim(PaperDownloaderFactory):
    """Public shim for manipulating internal cache/lock in tests."""

    __test__ = False  # avoid pytest test collection


def _cfg_obj(common_obj, services_map):
    """Build a fake hydra cfg structure with tools.paper_download."""
    tools = SimpleNamespace(
        paper_download=SimpleNamespace(common=common_obj, services=services_map)
    )
    return SimpleNamespace(tools=tools)


class _SlotsSource:
    """Object with __slots__ to avoid __dict__, forcing dir() fallback."""

    __slots__ = ("public_attr", "_private")

    def __init__(self, public_val, private_val):
        """initialize with public and private attributes."""
        self.public_attr = public_val
        self._private = private_val

    # 1st public method
    def peek(self):  # pragma: no cover - used in tiny coverage test
        """Return the public value."""
        return self.public_attr

    # 2nd public method to satisfy R0903
    def echo(self, value=None):  # pragma: no cover - used in tiny coverage test
        """Echo given value or the public attribute."""
        return self.public_attr if value is None else value


class _ItemsNoDict:
    """items()-only object (no __dict__) to force items() extraction path."""

    __slots__ = ("_data",)

    def __init__(self, data):
        """initialize with a dict-like data structure."""
        self._data = data

    def items(self):
        """implement items() to return the internal data."""
        return list(self._data.items())

    # add a second public method to satisfy R0903
    def size(self):  # pragma: no cover - simple helper to satisfy pylint
        """Return number of keys."""
        return len(self._data)


class _ExplodingItemsSlots:
    """items()-only object (no __dict__) that raises to hit _apply_config warning."""

    __slots__ = ()

    def items(self):  # pragma: no cover - we only care that it raises
        """implement items() that raises an error to test warning handling."""
        raise AttributeError("boom in items()")

    # add a second public method to satisfy R0903
    def noop(self):  # pragma: no cover - simple helper to satisfy pylint
        """No-op method."""
        return None


class TestPaperDownloaderFactory(unittest.TestCase):
    """Tests for PaperDownloaderFactory behavior via public APIs."""

    def setUp(self):
        """setup before each test."""
        PaperDownloaderFactory.clear_cache()

    def tearDown(self):
        """tear down after each test."""
        PaperDownloaderFactory.clear_cache()

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.ArxivDownloader"
    )
    @patch("aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.hydra")
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.GlobalHydra"
    )
    def test_create_arxiv_and_cached_config(
        self, mock_global_hydra, mock_hydra, mock_arxiv
    ):
        """First create loads config, second create returns cached config (no re-init)."""
        # First call: GlobalHydra not initialized
        mock_global_hydra.return_value.is_initialized.return_value = False
        # Common via __dict__ path; service via items() path
        common_obj = SimpleNamespace(request_timeout=15, chunk_size=4096)
        svc_obj = _ItemsNoDict({"api_url": "https://api", "extra": 1})
        mock_hydra.compose.return_value = _cfg_obj(common_obj, {"arxiv": svc_obj})

        # Create arxiv
        result1 = PaperDownloaderFactory.create("arxiv")
        self.assertIs(result1, mock_arxiv.return_value)
        mock_arxiv.assert_called_once()
        passed_cfg = mock_arxiv.call_args[0][0]
        self.assertTrue(passed_cfg.has_attribute("api_url"))
        self.assertEqual(passed_cfg.get_config_dict()["request_timeout"], 15)
        self.assertEqual(passed_cfg.get_config_dict()["chunk_size"], 4096)
        self.assertEqual(passed_cfg.get_config_dict()["api_url"], "https://api")

        # Second create (cached): hydra.initialize should not be called again
        mock_hydra.initialize.reset_mock()
        mock_hydra.compose.reset_mock()
        PaperDownloaderFactory.create("arxiv")
        mock_hydra.initialize.assert_not_called()
        mock_hydra.compose.assert_not_called()

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.MedrxivDownloader"
    )
    @patch("aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.hydra")
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.GlobalHydra"
    )
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.OmegaConf"
    )
    def test_create_medrxiv_omegaconf_and_clear_existing(
        self, mock_omegaconf, mock_global_hydra, mock_hydra, mock_medrxiv
    ):
        """When GlobalHydra is initialized, it should clear;
        OmegaConf extraction should populate fields."""
        PaperDownloaderFactory.clear_cache()
        # Force "already initialized" branch
        mock_global_hydra.return_value.is_initialized.return_value = True

        # OmegaConf conversion for both common and service
        common_oc = SimpleNamespace(_content=True)
        svc_oc = SimpleNamespace(_content=True)
        mock_omegaconf.to_container.side_effect = [
            {"request_timeout": 20, "chunk_size": 8192},
            {"api_url": "https://med", "pdf_url_template": "T"},
        ]
        mock_hydra.compose.return_value = _cfg_obj(common_oc, {"medrxiv": svc_oc})

        PaperDownloaderFactory.create("medrxiv")
        # GlobalHydra.instance().clear should be called once
        mock_global_hydra.instance.return_value.clear.assert_called_once()
        # Verify the config passed
        cfg = mock_medrxiv.call_args[0][0]
        cfg_d = cfg.get_config_dict()
        self.assertEqual(cfg_d["request_timeout"], 20)
        self.assertEqual(cfg_d["chunk_size"], 8192)
        self.assertEqual(cfg_d["api_url"], "https://med")
        self.assertEqual(cfg_d["pdf_url_template"], "T")

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.BiorxivDownloader"
    )
    @patch("aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.hydra")
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.GlobalHydra"
    )
    def test_create_biorxiv_dir_fallback(
        self, mock_global_hydra, mock_hydra, mock_biorxiv
    ):
        """dir() fallback path with __slots__ object should populate public, skip private."""
        mock_global_hydra.return_value.is_initialized.return_value = False
        common_obj = _SlotsSource(public_val=30, private_val="hide")
        svc_obj = _SlotsSource(public_val="https://biorxiv", private_val="x")
        mock_hydra.compose.return_value = _cfg_obj(common_obj, {"biorxiv": svc_obj})

        PaperDownloaderFactory.create("biorxiv")
        cfg = mock_biorxiv.call_args[0][0]
        cfg_d = cfg.get_config_dict()
        # Both "public_attr" from common and service appear;
        # service wins on name clash? not needed, just check present.
        self.assertIn("public_attr", cfg_d)
        self.assertEqual(cfg_d["public_attr"], "https://biorxiv")
        # Ensure private key not present
        self.assertNotIn("_private", cfg_d)

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.PubmedDownloader"
    )
    @patch("aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.hydra")
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.GlobalHydra"
    )
    def test_create_pubmed_apply_config_warning_path(
        self, mock_global_hydra, mock_hydra, mock_pubmed
    ):
        """If extraction raises in a path, _apply_config should log a warning and continue."""
        mock_global_hydra.return_value.is_initialized.return_value = False
        # First (common) will raise inside _extract_from_items -> warning
        common_obj = _ExplodingItemsSlots()
        # Service path is sane to still build config
        svc_obj = SimpleNamespace(
            api_url="https://pubmed", request_timeout=55, chunk_size=1024
        )
        mock_hydra.compose.return_value = _cfg_obj(common_obj, {"pubmed": svc_obj})

        with patch(
            "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.logger"
        ) as mock_logger:
            PaperDownloaderFactory.create("pubmed")
            # Warning logged once for common
            self.assertTrue(mock_logger.warning.called)

        cfg = mock_pubmed.call_args[0][0]
        cfg_d = cfg.get_config_dict()
        self.assertEqual(cfg_d["api_url"], "https://pubmed")
        self.assertEqual(cfg_d["request_timeout"], 55)
        self.assertEqual(cfg_d["chunk_size"], 1024)

    @patch("aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.hydra")
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.GlobalHydra"
    )
    def test_create_missing_service_error_message(self, mock_global_hydra, mock_hydra):
        """Missing service should raise ValueError with 'Service ... not found' message."""
        mock_global_hydra.return_value.is_initialized.return_value = False
        mock_hydra.compose.return_value = _cfg_obj(SimpleNamespace(), {"arxiv": {}})
        with self.assertRaises(ValueError) as ctx:
            PaperDownloaderFactory.create("unsupported")
        self.assertIn(
            "Service 'unsupported' not found in configuration", str(ctx.exception)
        )

    @patch("aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.hydra")
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.GlobalHydra"
    )
    def test_get_unified_config_failure_raises_runtimeerror(
        self, mock_global_hydra, mock_hydra
    ):
        """Hydra initialize failure should surface as RuntimeError from create()."""
        PaperDownloaderFactory.clear_cache()
        mock_global_hydra.return_value.is_initialized.return_value = False
        mock_hydra.initialize.side_effect = Exception("Config error")
        # Using arxiv path to trigger load; patch downloader to avoid import side-effects
        with patch(
            "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.ArxivDownloader"
        ):
            with self.assertRaises(RuntimeError):
                PaperDownloaderFactory.create("arxiv")


class TestDownloadPapersFunction(unittest.TestCase):
    """Tests for the download_papers tool and internal impl."""

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download."
        "paper_downloader.PaperDownloaderFactory.create"
    )
    def test_download_papers_success(self, mock_create):
        """Successful run returns article data and ToolMessage with summary."""
        dl = Mock()
        dl.get_service_name.return_value = "arXiv"
        dl.process_identifiers.return_value = {
            "1234.5678": {"Title": "T", "access_type": "open_access_downloaded"}
        }
        dl.build_summary.return_value = "Summary OK"
        mock_create.return_value = dl

        cmd = _download_papers_impl("arxiv", ["1234.5678"], "tid1")
        self.assertIsInstance(cmd, Command)
        self.assertIn("1234.5678", cmd.update["article_data"])
        msg = cmd.update["messages"][0]
        self.assertIsInstance(msg, ToolMessage)
        self.assertEqual(msg.tool_call_id, "tid1")
        self.assertEqual(msg.content, "Summary OK")

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download."
        "paper_downloader.PaperDownloaderFactory.get_default_service"
    )
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download."
        "paper_downloader.PaperDownloaderFactory.create"
    )
    def test_download_papers_none_service_uses_default(self, mock_create, mock_get_default):
        """When service=None, should use get_default_service() result."""
        mock_get_default.return_value = "pubmed"
        dl = Mock()
        dl.get_service_name.return_value = "PubMed"
        dl.process_identifiers.return_value = {
            "12345": {"Title": "Test", "access_type": "abstract_only"}
        }
        dl.build_summary.return_value = "PubMed Summary"
        mock_create.return_value = dl

        cmd = _download_papers_impl(None, ["12345"], "tid1")

        # Verify default service was requested
        mock_get_default.assert_called_once()
        # Verify create was called with the default service
        mock_create.assert_called_once_with("pubmed")

        self.assertIsInstance(cmd, Command)
        self.assertIn("12345", cmd.update["article_data"])

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download."
        "paper_downloader.PaperDownloaderFactory.create"
    )
    def test_download_papers_service_error_branch(self, mock_create):
        """ValueError from factory becomes a 'Service error' ToolMessage and empty data."""
        mock_create.side_effect = ValueError("Unsupported service: nope")
        cmd = _download_papers_impl("nope", ["x"], "tid2")
        self.assertEqual(cmd.update["article_data"], {})
        self.assertIn(
            "Error: Service error for 'nope': Unsupported service: nope",
            cmd.update["messages"][0].content,
        )

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download."
        "paper_downloader.PaperDownloaderFactory.create"
    )
    def test_download_papers_unexpected_error_branch(self, mock_create):
        """Unexpected error from downloader is caught and surfaced."""
        dl = Mock()
        dl.get_service_name.return_value = "arXiv"
        dl.process_identifiers.side_effect = RuntimeError("kaboom")
        mock_create.return_value = dl

        cmd = _download_papers_impl("arxiv", ["x"], "tid3")
        self.assertEqual(cmd.update["article_data"], {})
        self.assertIn(
            "Error: Unexpected error during paper download: kaboom",
            cmd.update["messages"][0].content,
        )

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader._download_papers_impl"
    )
    def test_convenience_wrappers(self, mock_impl):
        """The convenience functions forward to the core impl with the right service string."""
        mock_impl.return_value = Command(update={"ok": True})
        download_arxiv_papers(["a"], "tc1")
        mock_impl.assert_called_with("arxiv", ["a"], "tc1")
        download_medrxiv_papers(["b"], "tc2")
        mock_impl.assert_called_with("medrxiv", ["b"], "tc2")
        download_biorxiv_papers(["c"], "tc3")
        mock_impl.assert_called_with("biorxiv", ["c"], "tc3")
        download_pubmed_papers(["d"], "tc4")
        mock_impl.assert_called_with("pubmed", ["d"], "tc4")

    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader._download_papers_impl"
    )
    def test_tool_entry(self, mock_impl):
        """The download_papers tool entry should call the core impl."""
        mock_impl.return_value = Command(update={"ok": True})
        payload = {"service": "arxiv", "identifiers": ["123"], "tool_call_id": "tid"}
        result = download_papers.invoke(payload)
        mock_impl.assert_called_once_with("arxiv", ["123"], "tid")
        self.assertTrue(result.update["ok"])

    @patch("aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.hydra")
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.GlobalHydra"
    )
    def test_get_default_service_functionality(self, mock_global_hydra, mock_hydra):
        """Test get_default_service method with various configurations."""
        mock_global_hydra.return_value.is_initialized.return_value = False

        # Test default service from config
        common_cfg = SimpleNamespace(request_timeout=30, chunk_size=8192)
        services = {
            "arxiv": SimpleNamespace(api_url="https://arxiv.org"),
            "pubmed": SimpleNamespace(id_converter_url="https://pmc.ncbi.nlm.nih.gov")
        }
        tool_cfg = SimpleNamespace(default_service="arxiv")
        mock_hydra.compose.return_value = SimpleNamespace(
            tools=SimpleNamespace(
                paper_download=SimpleNamespace(
                    tool=tool_cfg, common=common_cfg, services=services
                )
            )
        )

        # Clear cache to ensure fresh config load
        PaperDownloaderFactory.clear_cache()
        result = PaperDownloaderFactory.get_default_service()
        self.assertEqual(result, "arxiv")

        # Test invalid default service fallback
        tool_cfg.default_service = "invalid_service"
        PaperDownloaderFactory.clear_cache()
        result = PaperDownloaderFactory.get_default_service()
        self.assertEqual(result, "pubmed")  # Should fallback to pubmed

        # Test missing default service (fallback to pubmed)
        mock_hydra.compose.return_value = SimpleNamespace(
            tools=SimpleNamespace(
                paper_download=SimpleNamespace(
                    tool=SimpleNamespace(), common=common_cfg, services=services
                )
            )
        )
        PaperDownloaderFactory.clear_cache()
        result = PaperDownloaderFactory.get_default_service()
        self.assertEqual(result, "pubmed")

        # Test medrxiv default service
        tool_cfg.default_service = "medrxiv"
        mock_hydra.compose.return_value = SimpleNamespace(
            tools=SimpleNamespace(
                paper_download=SimpleNamespace(
                    tool=tool_cfg, common=common_cfg, services=services
                )
            )
        )
        PaperDownloaderFactory.clear_cache()
        result = PaperDownloaderFactory.get_default_service()
        self.assertEqual(result, "medrxiv")

        # Test biorxiv default service
        tool_cfg.default_service = "biorxiv"
        PaperDownloaderFactory.clear_cache()
        result = PaperDownloaderFactory.get_default_service()
        self.assertEqual(result, "biorxiv")


class TestUnifiedConfigDoubleCheck(unittest.TestCase):
    """Covers the double-check return branch in _get_unified_config."""

    @patch("aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.hydra")
    @patch(
        "aiagents4pharma.talk2scholars.tools.paper_download.paper_downloader.GlobalHydra"
    )
    def test_double_check_inside_lock(self, mock_global_hydra, _mock_hydra):
        """tests the double-check branch in _get_unified_config using public create()."""
        # avoid real hydra init path if we accidentally go there
        mock_global_hydra.return_value.is_initialized.return_value = False

        # start clean
        _set_cached_config(None)

        class _LockCtx:
            """lock context manager that simulates another thread setting the cache."""

            def __enter__(self):
                # simulate another thread setting the cache while the lock is held
                _set_cached_config({"via": "enter"})
                return self

            def __exit__(self, exc_type, exc, tb):
                """exit context manager, no-op."""
                return False

        _set_config_lock(_LockCtx())

        # Patch build_service_config so we can assert the *exact* object returned by double-check
        with (
            patch.object(PaperDownloaderFactory, "_build_service_config") as mock_build,
            patch(
                "aiagents4pharma.talk2scholars.tools."
                "paper_download.paper_downloader.ArxivDownloader"
            ) as mock_arxiv,
        ):

            def _check_and_return(cfg, _svc):
                # ensure double-check returned our injected dict
                self.assertEqual(cfg, {"via": "enter"})
                # return a trivial config object for the downloader ctor
                return SimpleNamespace()

            mock_build.side_effect = _check_and_return

            # call public API; this will invoke the double-check path internally
            PaperDownloaderFactory.create("arxiv")
            mock_arxiv.assert_called_once()

        # cleanup
        _set_cached_config(None)
        _set_config_lock(None)


class TestHelperTinyCoverage(unittest.TestCase):
    """Covers tiny helper methods added to satisfy R0903."""

    def test_slots_source_helpers(self):
        """yields public_attr via peek() and echo() methods."""
        obj = _SlotsSource(public_val="x", private_val="y")
        self.assertEqual(obj.peek(), "x")
        self.assertEqual(obj.echo(), "x")
        self.assertEqual(obj.echo("z"), "z")

    def test_items_no_dict_size(self):
        """tests items() and size() methods of _ItemsNoDict."""
        data = {"a": 1, "b": 2}
        src = _ItemsNoDict(data)
        self.assertEqual(src.size(), 2)
        self.assertEqual(dict(src.items()), data)

    def test_exploding_items_noop(self):
        """tests the _ExplodingItemsSlots class that raises in items()."""
        src = _ExplodingItemsSlots()
        # we only call the extra public method (noop) for coverage;
        # .items() is meant to raise in other tests
        self.assertIsNone(src.noop())
