"""
Tests for the official filings module (src/data/filings/).

Tests cover:
1. FilingResult dataclass and report formatting
2. FilingRegistry suffix mapping and dispatch
3. EdinetFetcher availability and parsing
4. DuckDuckGo search fallback and result merging
5. get_official_filings tool integration

Run with: pytest tests/test_filings.py -v
"""

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from src.data.filings.base import FilingFetcher, FilingResult
from src.data.filings.edinet_fetcher import _extract_ticker_base
from src.data.filings.registry import FilingRegistry

# ============================================================================
# FilingResult Tests
# ============================================================================


class TestFilingResult:
    """Test FilingResult dataclass and its to_report_string() method."""

    def test_empty_result_reports_gaps(self):
        """An empty result should report all sections as not found."""
        result = FilingResult(source="TEST", ticker="1234.T")
        report = result.to_report_string()

        assert "TEST" in report
        assert "1234.T" in report
        assert "Segment data not found." in report
        assert "Ownership data not found." in report
        assert "Filing CF not found." in report

    def test_full_result_formats_correctly(self):
        """A fully populated result should format all sections."""
        result = FilingResult(
            source="EDINET",
            ticker="2767.T",
            filing_date="2025-06-30",
            filing_type="Annual",
            filing_url="https://edinet.example.com/doc123",
            major_shareholders=[
                {"name": "Bandai Namco Holdings", "percent": 49.12, "type": "parent"},
                {"name": "Japan Trustee Services", "percent": 5.3, "type": "trust"},
            ],
            parent_company={
                "name": "Bandai Namco Holdings",
                "percent": 49.12,
                "relationship": "subsidiary",
            },
            segments=[
                {
                    "name": "Amusement",
                    "revenue": "¥120,000M",
                    "op_profit": "¥15,000M",
                    "pct_of_total": 92.1,
                },
                {
                    "name": "Content & Digital",
                    "revenue": "¥10,300M",
                    "op_profit": "¥800M",
                    "pct_of_total": 7.9,
                },
            ],
            operating_cash_flow=10910000000,
            ocf_period="H1 FY2025",
            ocf_currency="JPY",
        )

        report = result.to_report_string()

        # Header
        assert "EDINET" in report
        assert "2767.T" in report
        assert "2025-06-30" in report
        assert "Annual" in report

        # Segments
        assert "Amusement" in report
        assert "92.1%" in report
        assert "Content & Digital" in report

        # Ownership
        assert "Bandai Namco Holdings" in report
        assert "49.12%" in report
        assert "subsidiary" in report

        # Cash flow
        assert "10,910,000,000" in report
        assert "H1 FY2025" in report
        assert "JPY" in report

    def test_segments_without_percentages(self):
        """Segments with None pct_of_total should still render."""
        result = FilingResult(
            source="TEST",
            ticker="1234.T",
            segments=[
                {
                    "name": "Division A",
                    "revenue": "¥50,000M",
                    "op_profit": "N/A",
                    "pct_of_total": None,
                },
            ],
        )
        report = result.to_report_string()
        assert "Division A" in report
        assert "N/A" in report

    def test_shareholders_without_parent(self):
        """Major shareholders present but no parent company."""
        result = FilingResult(
            source="TEST",
            ticker="7203.T",
            major_shareholders=[
                {"name": "Toyota Industries", "percent": 8.5, "type": "corporate"},
            ],
        )
        report = result.to_report_string()
        assert "Toyota Industries" in report
        assert "8.50%" in report
        assert "Ownership data not found." not in report

    def test_data_gaps_listed(self):
        """Data gaps should be listed at the end."""
        result = FilingResult(
            source="TEST",
            ticker="1234.T",
            data_gaps=["Shareholders not extracted", "OCF not found"],
        )
        report = result.to_report_string()
        assert "DATA GAPS" in report
        assert "Shareholders not extracted" in report
        assert "OCF not found" in report

    def test_geographic_breakdown(self):
        """Geographic breakdown should render when present."""
        result = FilingResult(
            source="TEST",
            ticker="1234.T",
            segments=[
                {
                    "name": "Main",
                    "revenue": "100",
                    "op_profit": "10",
                    "pct_of_total": 100,
                }
            ],
            geographic_breakdown=[
                {"region": "Japan", "revenue": "¥80,000M", "pct_of_total": 80.0},
                {"region": "Asia ex-JP", "revenue": "¥20,000M", "pct_of_total": 20.0},
            ],
        )
        report = result.to_report_string()
        assert "Japan" in report
        assert "80.0%" in report
        assert "Asia ex-JP" in report


# ============================================================================
# FilingRegistry Tests
# ============================================================================


class _MockFetcher(FilingFetcher):
    """Mock fetcher for testing the registry."""

    def __init__(self, suffixes, name, available=True, result=None):
        self._suffixes = suffixes
        self._name = name
        self._available = available
        self._result = result

    @property
    def supported_suffixes(self):
        return self._suffixes

    @property
    def source_name(self):
        return self._name

    def is_available(self):
        return self._available

    async def get_filing_data(self, ticker):
        return self._result


class TestFilingRegistry:
    """Test FilingRegistry suffix mapping and dispatch."""

    def test_register_and_lookup(self):
        """Registered fetcher should be found by suffix."""
        reg = FilingRegistry()
        fetcher = _MockFetcher(["T"], "EDINET")
        reg.register(fetcher)

        assert reg.get_fetcher("2767.T") is fetcher
        assert reg.get_fetcher("7203.T") is fetcher

    def test_unknown_suffix_returns_none(self):
        """Unknown suffix should return None."""
        reg = FilingRegistry()
        assert reg.get_fetcher("AAPL") is None
        assert reg.get_fetcher("0005.HK") is None

    def test_unavailable_fetcher_returns_none(self):
        """Unavailable fetcher (no API key) should return None."""
        reg = FilingRegistry()
        fetcher = _MockFetcher(["T"], "EDINET", available=False)
        reg.register(fetcher)
        assert reg.get_fetcher("2767.T") is None

    def test_multiple_suffixes_registered(self):
        """A fetcher with multiple suffixes should be found for each."""
        reg = FilingRegistry()
        fetcher = _MockFetcher(["KS", "KQ"], "DART")
        reg.register(fetcher)

        assert reg.get_fetcher("005930.KS") is fetcher
        assert reg.get_fetcher("035420.KQ") is fetcher

    @pytest.mark.asyncio
    async def test_fetch_returns_result(self):
        """fetch() should return the fetcher's result."""
        expected = FilingResult(source="EDINET", ticker="2767.T")
        reg = FilingRegistry()
        reg.register(_MockFetcher(["T"], "EDINET", result=expected))

        result = await reg.fetch("2767.T")
        assert result is expected

    @pytest.mark.asyncio
    async def test_fetch_no_fetcher_returns_none(self):
        """fetch() for unknown ticker returns None."""
        reg = FilingRegistry()
        result = await reg.fetch("AAPL")
        assert result is None

    @pytest.mark.asyncio
    async def test_fetch_handles_exception(self):
        """fetch() should catch exceptions and return None."""
        reg = FilingRegistry()

        class BrokenFetcher(_MockFetcher):
            async def get_filing_data(self, ticker):
                raise RuntimeError("boom")

        reg.register(BrokenFetcher(["T"], "BROKEN"))
        result = await reg.fetch("2767.T")
        assert result is None

    def test_available_suffixes(self):
        """available_suffixes should list only available fetchers."""
        reg = FilingRegistry()
        reg.register(_MockFetcher(["T"], "EDINET", available=True))
        reg.register(_MockFetcher(["KS"], "DART", available=False))

        assert "T" in reg.available_suffixes
        assert "KS" not in reg.available_suffixes


class TestEdinetTickerBaseExtraction:
    def test_alphanumeric_tokyo_base_preserved(self):
        assert _extract_ticker_base("262A.T") == "262A"

    def test_numeric_tokyo_base_preserved(self):
        assert _extract_ticker_base("2767.T") == "2767"


# ============================================================================
# EDINET Fetcher Tests
# ============================================================================


class TestEdinetFetcher:
    """Test EdinetFetcher availability and parsing logic."""

    def test_supported_suffixes(self):
        from src.data.filings.edinet_fetcher import EdinetFetcher

        fetcher = EdinetFetcher()
        assert fetcher.supported_suffixes == ["T"]
        assert fetcher.source_name == "EDINET"

    @patch("src.data.filings.edinet_fetcher.config")
    def test_is_available_with_key(self, mock_config):
        from src.data.filings.edinet_fetcher import EdinetFetcher

        mock_config.get_edinet_api_key.return_value = "test-key"
        fetcher = EdinetFetcher()
        assert fetcher.is_available() is True

    @patch("src.data.filings.edinet_fetcher.config")
    def test_is_available_without_key(self, mock_config):
        from src.data.filings.edinet_fetcher import EdinetFetcher

        mock_config.get_edinet_api_key.return_value = ""
        fetcher = EdinetFetcher()
        assert fetcher.is_available() is False

    @pytest.mark.asyncio
    @patch("src.data.filings.edinet_fetcher.config")
    async def test_get_filing_data_no_edinet_tools(self, mock_config):
        """Should return None if edinet-tools is not installed."""
        from src.data.filings.edinet_fetcher import EdinetFetcher

        mock_config.get_edinet_api_key.return_value = "test-key"
        fetcher = EdinetFetcher()

        with patch.dict("sys.modules", {"edinet_tools": None}):
            # Importing a module set to None in sys.modules raises ImportError
            result = await fetcher.get_filing_data("2767.T")
            # The import will succeed because edinet_tools is already installed
            # so we test via a different approach — mock the entity lookup
            # to return None (entity not found)

    @pytest.mark.asyncio
    async def test_get_filing_data_entity_not_found(self):
        """Should return None if entity is not found in EDINET."""
        pytest.importorskip("edinet_tools")
        from src.data.filings.edinet_fetcher import EdinetFetcher

        fetcher = EdinetFetcher()

        with patch("src.data.filings.edinet_fetcher.config") as mock_config:
            mock_config.get_edinet_api_key.return_value = "test-key"

            with patch("edinet_tools.entity", return_value=None):
                result = await fetcher.get_filing_data("9999.T")
                assert result is None

    @pytest.mark.asyncio
    async def test_get_filing_data_no_documents(self):
        """Should return result with data gaps if no documents found."""
        pytest.importorskip("edinet_tools")
        from src.data.filings.edinet_fetcher import EdinetFetcher

        fetcher = EdinetFetcher()
        mock_entity = MagicMock()
        mock_entity.name = "Test Corp"
        mock_entity.edinet_code = "E12345"
        mock_entity.documents.return_value = []

        with patch("src.data.filings.edinet_fetcher.config") as mock_config:
            mock_config.get_edinet_api_key.return_value = "test-key"

            with patch("edinet_tools.entity", return_value=mock_entity):
                result = await fetcher.get_filing_data("9999.T")
                assert result is not None
                assert result.source == "EDINET"
                assert len(result.data_gaps) > 0

    @pytest.mark.asyncio
    async def test_get_filing_data_with_parsed_document(self):
        """Should extract data from a parsed annual report."""
        pytest.importorskip("edinet_tools")
        from src.data.filings.edinet_fetcher import EdinetFetcher

        fetcher = EdinetFetcher()

        # Mock entity
        mock_entity = MagicMock()
        mock_entity.name = "Bandai Namco Amusement"
        mock_entity.edinet_code = "E04379"

        # Mock parsed document
        mock_parsed = MagicMock()
        mock_parsed.to_dict.return_value = {
            "jppfs_cor:CashFlowsFromUsedInOperatingActivities": 10910000000,
            "net_sales": 130000000000,
        }
        mock_parsed.fields.return_value = []

        # Mock document
        mock_doc = MagicMock()
        mock_doc.filing_datetime = "2025-06-30T00:00:00"
        mock_doc.doc_type_name = "有価証券報告書"
        mock_doc.doc_id = "S100TEST"
        mock_doc.parse.return_value = mock_parsed

        # Mock holding docs
        mock_holding_parsed = MagicMock()
        mock_holding_parsed.holder_name = "株式会社バンダイナムコホールディングス"
        mock_holding_parsed.ownership_pct = 49.12

        mock_holding_doc = MagicMock()
        mock_holding_doc.parse.return_value = mock_holding_parsed

        # Entity returns annual report + holding reports
        def mock_documents(days=30, doc_type=None):
            if doc_type == "120":
                return [mock_doc]
            elif doc_type == "350":
                return [mock_holding_doc]
            return [mock_doc]

        mock_entity.documents.side_effect = mock_documents

        with patch("src.data.filings.edinet_fetcher.config") as mock_config:
            mock_config.get_edinet_api_key.return_value = "test-key"

            with patch("edinet_tools.entity", return_value=mock_entity):
                result = await fetcher.get_filing_data("2767.T")

                assert result is not None
                assert result.source == "EDINET"
                assert result.ticker == "2767.T"

                # Cash flow extracted from XBRL key
                assert result.operating_cash_flow == 10910000000
                assert result.ocf_currency == "JPY"

                # Shareholders from holding reports
                assert result.major_shareholders is not None
                assert len(result.major_shareholders) == 1
                assert result.major_shareholders[0]["percent"] == 49.12

                # Parent company identified (>20%, <50% = equity_method)
                assert result.parent_company is not None
                assert result.parent_company["percent"] == 49.12
                assert result.parent_company["relationship"] == "equity_method"


# ============================================================================
# DuckDuckGo Search Merge Tests
# ============================================================================


class TestSearchMerge:
    """Test the DDG/Tavily result merging logic."""

    def test_merge_tavily_only(self):
        """Tavily-only results should pass through."""
        from src.tools.shared import _merge_search_results

        tavily = [
            {"title": "Result 1", "url": "https://example.com/1", "content": "..."},
            {"title": "Result 2", "url": "https://example.com/2", "content": "..."},
        ]
        merged = _merge_search_results(tavily, [])
        assert len(merged) == 2

    def test_merge_ddg_only(self):
        """DDG-only results should be normalized to Tavily format."""
        from src.tools.shared import _merge_search_results

        ddg = [
            {"title": "DDG Result", "href": "https://ddg.example.com", "body": "text"},
        ]
        merged = _merge_search_results(None, ddg)
        assert len(merged) == 1
        assert merged[0]["url"] == "https://ddg.example.com"
        assert merged[0]["content"] == "text"

    def test_merge_deduplicates_by_url(self):
        """Same URL from both sources should only appear once."""
        from src.tools.shared import _merge_search_results

        tavily = [
            {
                "title": "T Result",
                "url": "https://example.com/page",
                "content": "tavily",
            },
        ]
        ddg = [
            {"title": "D Result", "href": "https://example.com/page", "body": "ddg"},
        ]
        merged = _merge_search_results(tavily, ddg)
        assert len(merged) == 1
        # Tavily is primary
        assert merged[0]["content"] == "tavily"

    def test_merge_dedup_trailing_slash(self):
        """Trailing slash difference should not create duplicates."""
        from src.tools.shared import _merge_search_results

        tavily = [
            {"title": "T", "url": "https://example.com/page/", "content": "t"},
        ]
        ddg = [
            {"title": "D", "href": "https://example.com/page", "body": "d"},
        ]
        merged = _merge_search_results(tavily, ddg)
        assert len(merged) == 1

    def test_merge_unique_urls_kept(self):
        """Different URLs from DDG should be added after Tavily."""
        from src.tools.shared import _merge_search_results

        tavily = [
            {"title": "T", "url": "https://example.com/a", "content": "ta"},
        ]
        ddg = [
            {"title": "D", "href": "https://example.com/b", "body": "db"},
        ]
        merged = _merge_search_results(tavily, ddg)
        assert len(merged) == 2

    def test_merge_handles_dict_wrapper(self):
        """Tavily sometimes returns {'results': [...]}."""
        from src.tools.shared import _merge_search_results

        tavily = {
            "results": [
                {"title": "T", "url": "https://example.com/a", "content": "ta"},
            ]
        }
        ddg = [
            {"title": "D", "href": "https://example.com/b", "body": "db"},
        ]
        merged = _merge_search_results(tavily, ddg)
        assert len(merged) == 2

    def test_merge_handles_exceptions(self):
        """Exception objects from gather should not crash merge."""
        from src.tools.shared import _merge_search_results

        # These would be caught before merge in the actual code, but test robustness
        merged = _merge_search_results(None, None)
        assert merged == []


# ============================================================================
# DDG Search Function Tests
# ============================================================================


class TestDdgSearch:
    """Test the DuckDuckGo search wrapper."""

    @pytest.mark.asyncio
    async def test_ddg_search_import_failure(self):
        """Should return empty list if ddgs not installed."""
        from src.tools.shared import _ddg_search

        with patch.dict("sys.modules", {"ddgs": None}):
            result = await _ddg_search("test query")
            assert isinstance(result, list)

    @pytest.mark.asyncio
    async def test_ddg_search_returns_results(self):
        """Should return results when DDG search succeeds."""
        from src.tools.shared import _ddg_search

        mock_results = [
            {"title": "Test", "href": "https://example.com", "body": "test body"},
        ]

        mock_ddgs = MagicMock()
        mock_ddgs.text.return_value = mock_results

        with patch("ddgs.DDGS", return_value=mock_ddgs):
            result = await _ddg_search("test query")
            assert len(result) == 1
            assert result[0]["title"] == "Test"

    @pytest.mark.asyncio
    async def test_ddg_search_timeout_returns_empty_list(self):
        """Outer timeout should turn stalled DDG search into an empty result."""
        from src.tools.shared import _ddg_search

        class SlowDDGS:
            def __init__(self, *args, **kwargs):
                pass

            def text(self, *args, **kwargs):
                import time

                time.sleep(1)
                return []

        with patch("src.tools.shared.DDG_SEARCH_TIMEOUT_SECONDS", 0.01):
            with patch("ddgs.DDGS", SlowDDGS):
                result = await _ddg_search("test query")

        assert result == []


class TestForeignSourceSearch:
    @pytest.mark.asyncio
    async def test_search_foreign_sources_returns_tavily_when_ddg_stalls(self):
        from src.tools.research import search_foreign_sources

        with patch(
            "src.tools.research.shared.extract_company_name_async",
            new=AsyncMock(return_value="Sentia ASA"),
        ):
            with patch("src.tools.research.shared.tavily_tool", object()):
                with patch(
                    "src.tools.research.shared._tavily_search_with_timeout",
                    new=AsyncMock(
                        return_value={
                            "results": [
                                {
                                    "title": "IR page",
                                    "url": "https://example.com/ir",
                                    "content": "Official annual report link",
                                }
                            ]
                        }
                    ),
                ):
                    with patch(
                        "src.tools.research.shared._ddg_search",
                        new=AsyncMock(return_value=[]),
                    ):
                        result = await search_foreign_sources.ainvoke(
                            {
                                "ticker": "SNTIA.OL",
                                "search_query": "annual report investor relations",
                            }
                        )

        assert "Foreign Source Search Results" in result
        assert "IR page" in result
        assert "Tavily" in result


# ============================================================================
# get_official_filings Tool Tests
# ============================================================================


class TestGetOfficialFilingsTool:
    """Test the get_official_filings LangChain tool."""

    @pytest.mark.asyncio
    async def test_no_fetcher_available(self):
        """Should return informative message for unsupported markets."""
        from src.tools.research import get_official_filings

        with patch("src.data.filings.registry") as mock_registry:
            mock_registry.fetch = AsyncMock(return_value=None)
            result = await get_official_filings.ainvoke({"ticker": "AAPL"})
            assert "not available" in result.lower() or "No official" in result

    @pytest.mark.asyncio
    async def test_returns_formatted_result(self):
        """Should return formatted filing data when available."""
        from src.tools.research import get_official_filings

        mock_result = FilingResult(
            source="EDINET",
            ticker="2767.T",
            major_shareholders=[
                {"name": "Bandai Namco", "percent": 49.12, "type": "parent"},
            ],
            operating_cash_flow=10910000000,
            ocf_currency="JPY",
            ocf_period="FY2024",
        )

        with patch("src.data.filings.registry") as mock_registry:
            mock_registry.fetch = AsyncMock(return_value=mock_result)
            result = await get_official_filings.ainvoke({"ticker": "2767.T"})
            assert "EDINET" in result
            assert "Bandai Namco" in result
            assert "49.12%" in result
            assert "10,910,000,000" in result

    @pytest.mark.asyncio
    async def test_timeout_returns_degraded_message(self):
        """Timeout should degrade instead of hanging."""
        from src.tools.research import get_official_filings

        async def slow_fetch(_ticker):
            await asyncio.sleep(1)

        mock_registry = MagicMock()
        mock_registry.fetch = slow_fetch

        with patch("src.tools.research.OFFICIAL_FILINGS_TIMEOUT_SECONDS", 0.01):
            with patch("src.data.filings.registry", mock_registry):
                result = await get_official_filings.ainvoke({"ticker": "SNTIA.OL"})

        assert "timed out" in result.lower()
        assert "search_foreign_sources" in result


# ============================================================================
# Integration: Module Auto-Registration
# ============================================================================


class TestModuleAutoRegistration:
    """Test that __init__.py auto-registers available fetchers."""

    def test_import_does_not_crash(self):
        """Importing the filings module should not raise."""
        from src.data.filings import registry

        assert registry is not None

    def test_registry_has_edinet_when_available(self):
        """EDINET should be registered if edinet-tools is installed and key is set."""
        from src.data.filings.registry import registry

        # The auto-registration happens at import time
        # If EDINET_API_KEY is not set, the fetcher will be registered
        # but is_available() will return False
        # We just verify the T suffix is known (registered even if unavailable)
        fetcher = registry._fetchers.get("T")
        if fetcher is not None:
            assert fetcher.source_name == "EDINET"
