"""
Tests for Unified Parallel Data Fetcher

Verifies:
1. Parallel execution of all sources
2. Smart merging with quality scores
3. Mandatory Tavily gap-filling logic
4. Panic Mode for Asian tickers
"""

import asyncio
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pandas as pd
import pytest

from src.data.fetcher import SmartMarketDataFetcher
from src.yfinance_runtime import YFRateLimitError


@pytest.fixture
def fetcher():
    return SmartMarketDataFetcher()


class TestParallelExecution:
    """Verify all sources are called in parallel."""

    @pytest.mark.asyncio
    async def test_all_sources_called(self, fetcher):
        # Mock all fetch methods
        fetcher._fetch_yfinance_enhanced = AsyncMock(
            return_value={"symbol": "TEST", "currentPrice": 100}
        )
        fetcher._fetch_yahooquery_fallback = MagicMock(
            return_value={"pe": 15}
        )  # Not async
        fetcher._fetch_fmp_fallback = AsyncMock(return_value={"roe": 0.2})

        # Don't actually call Tavily
        fetcher._fetch_tavily_gaps = AsyncMock(return_value={})

        # Run
        result = await fetcher.get_financial_metrics("TEST")

        # Verify calls
        assert fetcher._fetch_yfinance_enhanced.called
        assert fetcher._fetch_fmp_fallback.called
        # Check if yahooquery wrapper was called (it's wrapped in to_thread)

        # Result should contain merged data
        assert result["currentPrice"] == 100
        assert result["pe"] == 15
        assert result["roe"] == 0.2

    @pytest.mark.asyncio
    async def test_yfinance_rate_limit_returns_none(self, fetcher):
        with patch(
            "src.data.fetcher.yf.Ticker",
            side_effect=YFRateLimitError(),
        ):
            result = await fetcher._fetch_yfinance_enhanced("TEST")

        assert result is None

    @pytest.mark.asyncio
    async def test_get_financial_metrics_uses_short_lived_cache(self, fetcher):
        """Repeated symbol fetches within a run should reuse cached merged results."""
        fetcher._get_financial_metrics_uncached = AsyncMock(
            return_value={"symbol": "TEST", "currentPrice": 100, "currency": "USD"}
        )

        first = await fetcher.get_financial_metrics("TEST")
        second = await fetcher.get_financial_metrics("TEST")

        assert first == second
        assert first is not second
        fetcher._get_financial_metrics_uncached.assert_awaited_once_with("TEST", 30)

    @pytest.mark.asyncio
    async def test_get_financial_metrics_coalesces_inflight_calls(self, fetcher):
        """Concurrent callers for the same symbol should share one underlying fetch."""
        started = asyncio.Event()
        release = asyncio.Event()

        async def slow_fetch(ticker, timeout):
            started.set()
            await release.wait()
            return {"symbol": ticker, "currentPrice": 100, "currency": "USD"}

        fetcher._get_financial_metrics_uncached = AsyncMock(side_effect=slow_fetch)

        first_task = asyncio.create_task(fetcher.get_financial_metrics("TEST"))
        await started.wait()
        second_task = asyncio.create_task(fetcher.get_financial_metrics("TEST"))
        release.set()

        first, second = await asyncio.gather(first_task, second_task)

        assert first == second
        assert fetcher._get_financial_metrics_uncached.await_count == 1

    @pytest.mark.asyncio
    async def test_get_financial_metrics_clears_inflight_after_unexpected_error(
        self, fetcher
    ):
        """Unexpected exceptions should not poison future same-symbol fetches."""
        fetcher._get_financial_metrics_uncached = AsyncMock(
            side_effect=RuntimeError("boom")
        )

        with pytest.raises(RuntimeError, match="boom"):
            await fetcher.get_financial_metrics("TEST")

        fetcher._get_financial_metrics_uncached = AsyncMock(
            return_value={"symbol": "TEST", "currentPrice": 100, "currency": "USD"}
        )
        result = await fetcher.get_financial_metrics("TEST")

        assert result["symbol"] == "TEST"

    @pytest.mark.asyncio
    async def test_get_financial_metrics_does_not_cache_error_payload(self, fetcher):
        """Error payloads should not be reused as successful cached results."""
        fetcher._get_financial_metrics_uncached = AsyncMock(
            side_effect=[
                {"error": "temporary", "symbol": "TEST"},
                {"symbol": "TEST", "currentPrice": 100, "currency": "USD"},
            ]
        )

        first = await fetcher.get_financial_metrics("TEST")
        second = await fetcher.get_financial_metrics("TEST")

        assert first["error"] == "temporary"
        assert second["currentPrice"] == 100
        assert fetcher._get_financial_metrics_uncached.await_count == 2

    @pytest.mark.asyncio
    async def test_get_price_history_uses_short_lived_cache(self, fetcher):
        """Repeated history fetches for the same symbol/period should reuse cached data."""
        frame = pd.DataFrame({"Close": [1.0, 2.0], "Volume": [10, 20]})
        fetcher._get_price_history_uncached = AsyncMock(return_value=frame)

        first = await fetcher.get_price_history("TEST", period="1y")
        second = await fetcher.get_price_history("TEST", period="1y")

        assert first.equals(second)
        assert first is not second
        fetcher._get_price_history_uncached.assert_awaited_once_with(
            "TEST",
            "1y",
            start=None,
            end=None,
        )

    @pytest.mark.asyncio
    async def test_get_price_history_uses_distinct_cache_keys_for_date_bounds(
        self, fetcher
    ):
        """Bounded and unbounded history requests should not collide in cache."""
        fetcher._get_price_history_uncached = AsyncMock(
            side_effect=[
                pd.DataFrame({"Close": [1.0]}),
                pd.DataFrame({"Close": [2.0]}),
            ]
        )

        first = await fetcher.get_price_history("TEST", period="1y")
        second = await fetcher.get_price_history(
            "TEST",
            period="1y",
            start="2025-01-01",
            end="2025-03-31",
        )

        assert first.iloc[-1]["Close"] == 1.0
        assert second.iloc[-1]["Close"] == 2.0
        assert fetcher._get_price_history_uncached.await_args_list[0].args == (
            "TEST",
            "1y",
        )
        assert fetcher._get_price_history_uncached.await_args_list[0].kwargs == {
            "start": None,
            "end": None,
        }
        assert fetcher._get_price_history_uncached.await_args_list[1].kwargs == {
            "start": "2025-01-01",
            "end": "2025-03-31",
        }

    @pytest.mark.asyncio
    async def test_get_price_history_forwards_start_and_end_to_yfinance(self, fetcher):
        """Explicit history bounds should use start/end instead of the default period."""
        mock_stock = MagicMock()
        expected = pd.DataFrame({"Close": [1.0, 2.0]})
        mock_stock.history.return_value = expected

        with patch("src.data.fetcher.yf.Ticker", return_value=mock_stock):
            result = await fetcher.get_price_history(
                "TEST",
                start="2025-01-01",
                end="2025-03-31",
            )

        mock_stock.history.assert_called_once_with(
            start="2025-01-01",
            end="2025-03-31",
        )
        assert result.equals(expected)


class TestSmartMerge:
    """Verify quality-based merging."""

    def test_quality_priority(self, fetcher):
        # Setup conflicting data
        source_results = {
            "yfinance": {
                "trailingPE": 10.0,
                "_trailingPE_source": "yfinance_info",
            },  # Quality 9
            "yahooquery": {"trailingPE": 20.0},  # Quality 6
            "fmp": {"trailingPE": 30.0},  # Quality 7
        }

        # NOTE: Function returns (merged, metadata) tuple
        merged, meta = fetcher._smart_merge_with_quality(source_results, "TEST")

        # Should keep yfinance (highest quality)
        assert merged["trailingPE"] == 10.0
        assert meta["field_sources"]["trailingPE"] == "yfinance"

    def test_fmp_over_yahooquery(self, fetcher):
        # Setup conflicting data (no yfinance)
        source_results = {
            "yfinance": None,
            "yahooquery": {"trailingPE": 20.0},  # Quality 6
            "fmp": {"trailingPE": 30.0},  # Quality 7
        }

        # NOTE: Function returns (merged, metadata) tuple
        merged, meta = fetcher._smart_merge_with_quality(source_results, "TEST")

        # Should keep FMP (7 > 6)
        assert merged["trailingPE"] == 30.0
        assert meta["field_sources"]["trailingPE"] == "fmp"

    def test_percent_like_conflicts_normalize_decimal_and_percent(self, fetcher):
        source_results = {
            "yfinance": {"regularMarketChangePercent": -1.4177},
            "yahooquery": {"regularMarketChangePercent": -0.0142},
        }

        merged, meta = fetcher._smart_merge_with_quality(source_results, "6083.T")

        assert merged["regularMarketChangePercent"] == -1.4177
        assert "regularMarketChangePercent" not in meta["source_conflicts"]

    def test_percent_like_conflicts_normalize_sub_one_percent_values(self, fetcher):
        source_results = {
            "yfinance": {"regularMarketChangePercent": 0.4061},
            "yahooquery": {"regularMarketChangePercent": 0.0041},
        }

        merged, meta = fetcher._smart_merge_with_quality(source_results, "7609.T")

        assert merged["regularMarketChangePercent"] == 0.4061
        assert "regularMarketChangePercent" not in meta["source_conflicts"]

    def test_metadata_only_fields_do_not_emit_source_conflicts(self, fetcher):
        source_results = {
            "yfinance": {"maxAge": 86400.0},
            "yahooquery": {"maxAge": 1.0},
        }

        merged, meta = fetcher._smart_merge_with_quality(source_results, "TEST")

        assert merged["maxAge"] == 86400.0
        assert "maxAge" not in meta["source_conflicts"]

    def test_return_on_equity_conflicts_are_not_blindly_rescaled(self, fetcher):
        source_results = {
            "yfinance": {"returnOnEquity": 2.0},
            "yahooquery": {"returnOnEquity": 0.20},
        }

        merged, meta = fetcher._smart_merge_with_quality(source_results, "TEST")

        assert merged["returnOnEquity"] == 2.0
        assert "returnOnEquity" in meta["source_conflicts"]
        conflict = meta["source_conflicts"]["returnOnEquity"]
        assert {conflict["old"], conflict["new"]} == {2.0, 0.2}

    def test_microstructure_fields_do_not_emit_source_conflicts(self, fetcher):
        source_results = {
            "yfinance": {
                "bidSize": 10760.0,
                "askSize": 10000.0,
                "bid": 1.23,
                "ask": 1.25,
            },
            "yahooquery": {
                "bidSize": 1076000.0,
                "askSize": 1000000.0,
                "bid": 123.0,
                "ask": 125.0,
            },
        }

        merged, meta = fetcher._smart_merge_with_quality(source_results, "RY4C.DE")

        assert merged["bidSize"] == 10760.0
        assert merged["askSize"] == 10000.0
        assert merged["bid"] == 1.23
        assert merged["ask"] == 1.25
        assert "bidSize" not in meta["source_conflicts"]
        assert "askSize" not in meta["source_conflicts"]
        assert "bid" not in meta["source_conflicts"]
        assert "ask" not in meta["source_conflicts"]

    def test_resolved_by_quality_forward_pe_conflicts_stay_in_metadata(self, fetcher):
        source_results = {
            "yfinance": {"forwardPE": 8.1212},
            "yahooquery": {"forwardPE": 77.9191},
        }

        merged, meta = fetcher._smart_merge_with_quality(source_results, "SEA1.OL")

        assert merged["forwardPE"] == 8.1212
        conflict = meta["source_conflicts"]["forwardPE"]
        assert conflict["field_class"] == "valuation"
        assert conflict["resolved_by_quality"] is True
        assert conflict["winner_quality"] == 9
        assert conflict["loser_quality"] == 6


class TestTavilyGapFilling:
    """Verify mandatory gap filling logic."""

    @pytest.mark.asyncio
    async def test_tavily_triggered_low_coverage(self, fetcher):
        # Mock basic data (low coverage)
        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={
                "yfinance": {"symbol": "TEST", "currentPrice": 100, "currency": "USD"}
            }
        )

        # Mock Tavily success
        fetcher._fetch_tavily_gaps = AsyncMock(
            return_value={"trailingPE": 15.5, "marketCap": 1000000000}
        )

        # Run
        result = await fetcher.get_financial_metrics("TEST")

        # Verify Tavily was called
        assert fetcher._fetch_tavily_gaps.called

        # Verify data was merged
        assert result["trailingPE"] == 15.5
        assert "_gaps_filled" in result
        assert result["_gaps_filled"] > 0

    @pytest.mark.asyncio
    async def test_tavily_skipped_high_coverage(self, fetcher):
        # Mock high coverage data (includes sector/industry added to IMPORTANT_FIELDS Jan 2026)
        full_data = {
            "symbol": "TEST",
            "currentPrice": 100,
            "currency": "USD",
            "sector": "Technology",  # Added to IMPORTANT_FIELDS Jan 2026
            "industry": "Software",  # Added to IMPORTANT_FIELDS Jan 2026
            "trailingPE": 15,
            "marketCap": 100,
            "priceToBook": 2,
            "returnOnEquity": 0.15,
            "revenueGrowth": 0.1,
            "profitMargins": 0.2,
            "debtToEquity": 0.5,
            "currentRatio": 1.5,
            "freeCashflow": 1000,
            "operatingCashflow": 1000,
            "numberOfAnalystOpinions": 5,
        }

        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={"yfinance": full_data}
        )

        fetcher._fetch_tavily_gaps = AsyncMock()

        await fetcher.get_financial_metrics("TEST")

        # Tavily should NOT be called
        assert not fetcher._fetch_tavily_gaps.called


class TestPanicMode:
    """Verify 'Panic Mode' for Asian tickers."""

    @pytest.mark.asyncio
    async def test_panic_mode_trigger_asian_stocks(self, fetcher):
        """
        REGRESSION TEST: Verify that if yfinance returns NOTHING for an Asian ticker,
        the system triggers 'Panic Mode' and calls Tavily for ALL critical fields.

        UPDATED: Test now reflects actual behavior where DANGEROUS_FIELDS
        (trailingPE, forwardPE, pegRatio, currentPrice, marketCap) are filtered out
        from Tavily requests to prevent hallucinated financial data.
        """
        ticker = "0005.HK"

        # Mock total failure from all standard sources
        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={"yfinance": None, "yahooquery": None, "fmp": None}
        )

        # Mock Tavily rescue - it returns data even for dangerous fields
        # when the mock is called directly (simulating successful web extraction)
        fetcher._fetch_tavily_gaps = AsyncMock(
            return_value={
                "returnOnEquity": 0.18,
                "returnOnAssets": 0.12,
                "debtToEquity": 0.45,
                "currentRatio": 1.8,
                "operatingMargins": 0.22,
                "grossMargins": 0.35,
                "profitMargins": 0.15,
                "revenueGrowth": 0.08,
                "earningsGrowth": 0.10,
            }
        )

        data = await fetcher.get_financial_metrics(ticker)

        # Verify Tavily was called in panic mode (should be called twice: once for panic, once for mandatory gap fill)
        assert fetcher._fetch_tavily_gaps.called
        assert fetcher._fetch_tavily_gaps.call_count >= 1

        # CRITICAL: We need to check the FIRST call (panic mode), not the last call
        # The first call is made during panic mode with IMPORTANT_FIELDS + REQUIRED_BASICS
        # The second call (if any) is the mandatory gap-fill phase with remaining gaps
        first_call_args = fetcher._fetch_tavily_gaps.call_args_list[0]
        requested_fields = first_call_args[0][1]  # Second positional argument

        # CRITICAL FIX: The actual implementation filters out DANGEROUS_FIELDS before
        # making Tavily requests. These include: trailingPE, forwardPE, pegRatio,
        # currentPrice, and marketCap to prevent hallucinated financial data.
        #
        # In panic mode, the code passes IMPORTANT_FIELDS + REQUIRED_BASICS to _fetch_tavily_gaps,
        # but _fetch_tavily_gaps filters out dangerous fields internally.
        #
        # We should verify that:
        # 1. The panic mode call includes MANY fields (before filtering)
        # 2. The test should verify the behavior, not inspect internal filtering

        # The panic mode call passes IMPORTANT_FIELDS (15 fields) + REQUIRED_BASICS (3 fields)
        # That's ~18 fields passed to _fetch_tavily_gaps
        # After filtering dangerous fields internally, safe fields remain
        assert (
            len(requested_fields) >= 10
        ), f"Expected panic mode to request many fields (IMPORTANT_FIELDS + REQUIRED_BASICS), got: {len(requested_fields)}"

        # Verify rescue was successful for safe fields
        # The mock returns these fields, and they should appear in the final data
        assert data.get("returnOnEquity") == 0.18
        assert data.get("returnOnAssets") == 0.12

    @pytest.mark.asyncio
    async def test_ibkr_rescue_skipped_for_healthy_fetch(self, fetcher):
        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={
                "yfinance": {
                    "symbol": "3600.HK",
                    "currentPrice": 4.56,
                    "currency": "HKD",
                    "longName": "Modern Dental Group",
                    "sector": "Health Care",
                    "industry": "Medical Devices",
                    "marketCap": 1_000_000_000,
                }
            }
        )
        fetcher._fetch_tavily_gaps = AsyncMock(return_value={})
        fetcher._probe_ibkr_security = AsyncMock()

        result = await fetcher.get_financial_metrics("3600.HK")

        assert result["currentPrice"] == 4.56
        fetcher._probe_ibkr_security.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_ibkr_rescue_can_retry_with_corrected_ticker_before_tavily(
        self, fetcher
    ):
        probe = SimpleNamespace(
            identity_confidence="VERIFIED",
            resolved_yf_ticker="BEC.SI",
            company_name="BRC Asia",
            currency="SGD",
            market_data_availability="R",
            last_price=2.34,
            error_kind=None,
        )

        fetcher._fetch_all_sources_parallel = AsyncMock(
            side_effect=[
                {"yfinance": None, "yahooquery": None, "fmp": None},
                {
                    "yfinance": {
                        "symbol": "BEC.SI",
                        "currentPrice": 2.34,
                        "currency": "SGD",
                        "longName": "BRC Asia",
                        "sector": "Industrials",
                    }
                },
            ]
        )
        fetcher._fetch_tavily_gaps = AsyncMock(return_value={})
        fetcher._probe_ibkr_security = AsyncMock(return_value=probe)

        result = await fetcher.get_financial_metrics("BEC.SG")

        assert result["symbol"] == "BEC.SI"
        assert result["currency"] == "SGD"
        assert result["currentPrice"] == 2.34
        assert result["longName"] == "BRC Asia"
        assert result["_ibkr_identity_confidence"] == "VERIFIED"
        fetcher._probe_ibkr_security.assert_awaited_once_with("BEC.SG")
        assert fetcher._fetch_all_sources_parallel.await_count == 2

    @pytest.mark.asyncio
    async def test_ibkr_rescue_does_not_override_existing_statement_fields(
        self, fetcher
    ):
        probe = SimpleNamespace(
            identity_confidence="VERIFIED",
            resolved_yf_ticker="3600.HK",
            company_name="Modern Dental Group",
            currency="HKD",
            market_data_availability="R",
            last_price=4.56,
            error_kind=None,
        )
        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={
                "yfinance": {
                    "symbol": "3600.HK",
                    "currency": "HKD",
                    "operatingCashflow": 123456789,
                }
            }
        )
        fetcher._fetch_tavily_gaps = AsyncMock(return_value={})
        fetcher._probe_ibkr_security = AsyncMock(return_value=probe)

        result = await fetcher.get_financial_metrics("3600.HK")

        assert result["operatingCashflow"] == 123456789
        assert result["currentPrice"] == 4.56

    @pytest.mark.asyncio
    async def test_ibkr_rescue_triggered_on_identity_weak(self, fetcher):
        probe = SimpleNamespace(
            identity_confidence="UNVERIFIED",
            resolved_yf_ticker=None,
            company_name=None,
            currency=None,
            market_data_availability=None,
            last_price=None,
            error_kind="NOT_CONFIGURED",
        )
        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={
                "yfinance": {"symbol": "BEC.SG", "operatingCashflow": 99_000_000}
            }
        )
        fetcher._fetch_tavily_gaps = AsyncMock(return_value={})
        fetcher._probe_ibkr_security = AsyncMock(return_value=probe)

        await fetcher.get_financial_metrics("BEC.SG")

        fetcher._probe_ibkr_security.assert_awaited_once_with("BEC.SG")

    @pytest.mark.asyncio
    async def test_ambiguous_ibkr_probe_sets_metadata_without_retry(self, fetcher):
        probe = SimpleNamespace(
            identity_confidence="AMBIGUOUS",
            resolved_yf_ticker=None,
            company_name=None,
            currency=None,
            market_data_availability=None,
            last_price=None,
            error_kind="AMBIGUOUS",
        )
        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={
                "yfinance": {"symbol": "BEC.SG", "operatingCashflow": 99_000_000}
            }
        )
        fetcher._fetch_tavily_gaps = AsyncMock(return_value={})
        fetcher._probe_ibkr_security = AsyncMock(return_value=probe)

        result = await fetcher.get_financial_metrics("BEC.SG")

        assert result["_ibkr_identity_confidence"] == "AMBIGUOUS"
        assert result["_ibkr_probe_error_kind"] == "AMBIGUOUS"
        assert fetcher._fetch_all_sources_parallel.await_count == 1

    @pytest.mark.asyncio
    async def test_dangerous_fields_filtering(self, fetcher):
        """
        Verify that _fetch_tavily_gaps correctly filters out DANGEROUS_FIELDS
        even when they're passed in the missing_fields list.
        """
        # Setup mock Tavily state
        fetcher.tavily_client = MagicMock()

        # Call with both safe and dangerous fields
        missing_fields = [
            "trailingPE",  # DANGEROUS
            "forwardPE",  # DANGEROUS
            "currentPrice",  # DANGEROUS
            "marketCap",  # DANGEROUS
            "pegRatio",  # DANGEROUS
            "returnOnEquity",  # SAFE
            "returnOnAssets",  # SAFE
            "debtToEquity",  # SAFE
        ]

        with patch("src.data.fetcher.search_tavily_inspected") as mock_search:
            mock_search.return_value = {"results": [{"content": "ROE is 15%"}]}
            result = await fetcher._fetch_tavily_gaps("TEST", missing_fields)

        # Verify only safe-field searches are issued
        assert mock_search.called
        for call in mock_search.call_args_list:
            query = call.args[0]
            assert not all(
                term in query.lower() for term in ["p/e", "price", "market cap"]
            )

    @pytest.mark.asyncio
    async def test_tavily_gap_fill_name_lookup_timeout_falls_back_to_symbol(
        self, fetcher
    ):
        """Timed-out company-name lookups should not block gap filling."""
        fetcher.tavily_client = MagicMock()
        original_wait_for = asyncio.wait_for

        async def slow_to_thread(_func):
            await asyncio.sleep(1)

        async def fast_timeout(awaitable, timeout):
            return await original_wait_for(awaitable, timeout=0.01)

        with patch("src.data.fetcher.yf.Ticker", return_value=MagicMock()):
            with patch(
                "src.data.fetcher.asyncio.to_thread",
                side_effect=slow_to_thread,
            ):
                with patch(
                    "src.data.fetcher.asyncio.wait_for", side_effect=fast_timeout
                ):
                    with patch(
                        "src.data.fetcher.generate_strict_search_query",
                        side_effect=lambda symbol, company_name, term: (
                            f"{symbol}|{company_name}|{term}"
                        ),
                    ):
                        with patch(
                            "src.data.fetcher.search_tavily_inspected"
                        ) as mock_search:
                            mock_search.return_value = {"results": []}
                            await fetcher._fetch_tavily_gaps("TEST", ["returnOnEquity"])

        query = mock_search.call_args.args[0]
        assert query.startswith("TEST|TEST|")


class TestCalculatedMetrics:
    """Test metrics calculated from other fields."""

    def test_derived_calculations(self, fetcher):
        data = {
            "returnOnAssets": 0.10,
            "debtToEquity": 1.0,
            "trailingPE": 20.0,
            "earningsGrowth": 0.20,
            "currentPrice": 100.0,
            "sharesOutstanding": 1000000,
        }

        derived = fetcher._calculate_derived_metrics(data, "TEST")

        # ROE = ROA * (1 + D/E) = 0.10 * 2 = 0.20
        assert derived["returnOnEquity"] == 0.20

        # PEG calculation is DISABLED (Jan 2026) - time horizon mismatch
        # between quarterly earningsGrowth and TTM P/E produces unreliable ratios
        assert "pegRatio" not in derived, "PEG fallback calculation should be disabled"

        # Market Cap
        assert derived["marketCap"] == 100000000.0


class TestWebFallback:
    """Test web searching capabilities."""

    def test_selective_web_fill(self, fetcher):
        """Ensure regex extract works for key metrics."""
        text = "Market Cap: 300T. P/E Ratio: 15.5"

        extractor = fetcher.pattern_extractor
        result = extractor.extract_from_text(text)

        assert result["trailingPE"] == 15.5
        # 300T = 300 * 1e12 = 300,000,000,000,000.0
        assert result["marketCap"] == 300000000000000.0


class TestFXCaching:
    """Test currency caching logic."""

    def test_fx_cache_hit(self, fetcher):
        """Test that cached FX rates are used."""
        cache_key = "EUR_USD"

        # Manually set a valid cache entry
        from datetime import timedelta

        future_time = datetime.now() + timedelta(hours=1)

        fetcher.fx_cache = {cache_key: 0.85}
        fetcher.fx_cache_expiry_time = {cache_key: future_time}

        # Call
        rate = fetcher.get_currency_rate("EUR", "USD")

        # Verify
        assert rate == 0.85


class TestQuoteUnitNormalization:
    def test_normalize_quote_unit_mismatch_stj_like_case(self, fetcher):
        info = {
            "currency": "GBp",
            "financialCurrency": "GBP",
            "currentPrice": 1260.0,
            "regularMarketPrice": 1260.0,
            "previousClose": 1250.0,
            "bookValue": 2.852,
            "trailingEps": 0.99,
            "forwardEps": 1.00582,
            "epsCurrentYear": 0.78033,
            "sharesOutstanding": 513052162,
            "marketCap": 6467597312,
            "priceToBook": 441.79523,
            "priceEpsCurrentYear": 1614.7014,
        }

        result = fetcher._normalize_data_integrity(info.copy(), "STJ.L")

        assert result["currency"] == "GBP"
        assert result["currentPrice"] == pytest.approx(12.6)
        assert result["regularMarketPrice"] == pytest.approx(12.6)
        assert result["previousClose"] == pytest.approx(12.5)
        assert result["priceToBook"] == pytest.approx(12.6 / 2.852)
        assert result["priceEpsCurrentYear"] == pytest.approx(12.6 / 0.78033)
        assert result["trailingPE"] == pytest.approx(12.6 / 0.99)
        assert result["forwardPE"] == pytest.approx(12.6 / 1.00582)
        assert result["_unit_normalization"]["reason"] == (
            "triangle_verified_quote_unit_mismatch"
        )

    def test_normalize_quote_unit_mismatch_requires_corroboration_without_alias(
        self, fetcher
    ):
        info = {
            "currency": "USD",
            "financialCurrency": "USD",
            "currentPrice": 1000.0,
            "sharesOutstanding": 100,
            "marketCap": 1000.0,
            "trailingEps": 50.0,
            "trailingPE": 9.0,
        }

        result = fetcher._normalize_data_integrity(info.copy(), "TEST")

        assert result["currentPrice"] == 1000.0
        assert "_unit_normalization" not in result

    def test_normalize_quote_unit_mismatch_skips_when_corrected_triangle_fails(
        self, fetcher
    ):
        info = {
            "currency": "GBp",
            "financialCurrency": "GBP",
            "currentPrice": 1260.0,
            "sharesOutstanding": 100,
            "marketCap": 250.0,
            "trailingEps": 0.99,
            "trailingPE": 12.727273,
        }

        result = fetcher._normalize_data_integrity(info.copy(), "TEST.L")

        assert result["currentPrice"] == 1260.0
        assert "_unit_normalization" not in result

    def test_pe_divergence_suspicious_does_not_trigger_unit_normalization(
        self, fetcher
    ):
        info = {
            "currency": "CAD",
            "financialCurrency": "CAD",
            "currentPrice": 16.0,
            "sharesOutstanding": 100,
            "marketCap": 1600.0,
            "trailingPE": 7.777777,
            "forwardPE": 2.109826,
        }

        result = fetcher._normalize_data_integrity(info.copy(), "STGO.TO")

        assert result["currentPrice"] == 16.0
        assert result["trailingPE"] == pytest.approx(7.777777)
        assert result["forwardPE"] == pytest.approx(2.109826)
        assert "_unit_normalization" not in result

    @pytest.mark.asyncio
    async def test_get_financial_metrics_normalizes_before_triangle_and_skips_eodhd(
        self, fetcher
    ):
        stj_like = {
            "symbol": "STJ.L",
            "currency": "GBp",
            "financialCurrency": "GBP",
            "currentPrice": 1260.0,
            "regularMarketPrice": 1260.0,
            "previousClose": 1250.0,
            "bookValue": 2.852,
            "trailingEps": 0.99,
            "forwardEps": 1.00582,
            "epsCurrentYear": 0.78033,
            "sharesOutstanding": 513052162,
            "marketCap": 6467597312,
            "priceToBook": 441.79523,
            "priceEpsCurrentYear": 1614.7014,
            "sector": "Healthcare",
            "industry": "Medical Devices",
            "returnOnEquity": 0.15,
            "revenueGrowth": 0.08,
            "profitMargins": 0.12,
            "debtToEquity": 0.4,
            "currentRatio": 1.4,
            "freeCashflow": 1_000_000.0,
            "operatingCashflow": 1_500_000.0,
            "numberOfAnalystOpinions": 7,
        }

        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={"yfinance": stj_like}
        )
        fetcher._fetch_tavily_gaps = AsyncMock(return_value={})
        fetcher.eodhd_fetcher = MagicMock()
        fetcher.eodhd_fetcher.verify_anchor_metrics = AsyncMock(
            return_value={"marketCap": 1.0}
        )

        result = await fetcher.get_financial_metrics("STJ.L")

        assert result["currency"] == "GBP"
        assert result["currentPrice"] == pytest.approx(12.6)
        assert result["_unit_normalization"]["reason"] == (
            "triangle_verified_quote_unit_mismatch"
        )
        assert "_eodhd_arbitration" not in result
        assert "_data_quality_flag" not in result
        fetcher.eodhd_fetcher.verify_anchor_metrics.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_get_financial_metrics_exposes_coherent_normalized_snapshot(
        self, fetcher
    ):
        stj_like = {
            "symbol": "STJ.L",
            "currency": "GBp",
            "financialCurrency": "GBP",
            "currentPrice": 1260.0,
            "regularMarketPrice": 1260.0,
            "previousClose": 1250.0,
            "bid": 1258.0,
            "ask": 1262.0,
            "fiftyDayAverage": 1180.0,
            "twoHundredDayAverage": 1105.0,
            "fiftyTwoWeekLow": 980.0,
            "fiftyTwoWeekHigh": 1290.0,
            "bookValue": 2.852,
            "trailingEps": 0.99,
            "forwardEps": 1.00582,
            "epsCurrentYear": 0.78033,
            "sharesOutstanding": 513052162,
            "marketCap": 6467597312,
            "priceToBook": 441.79523,
            "priceEpsCurrentYear": 1614.7014,
            "sector": "Healthcare",
            "industry": "Medical Devices",
            "returnOnEquity": 0.15,
            "revenueGrowth": 0.08,
            "profitMargins": 0.12,
            "debtToEquity": 0.4,
            "currentRatio": 1.4,
            "freeCashflow": 1_000_000.0,
            "operatingCashflow": 1_500_000.0,
            "numberOfAnalystOpinions": 7,
        }

        fetcher._fetch_all_sources_parallel = AsyncMock(
            return_value={"yfinance": stj_like}
        )
        fetcher._fetch_tavily_gaps = AsyncMock(return_value={})

        result = await fetcher.get_financial_metrics("STJ.L")

        assert result["currentPrice"] == pytest.approx(12.6)
        assert result["regularMarketPrice"] == pytest.approx(12.6)
        assert result["previousClose"] == pytest.approx(12.5)
        assert result["bid"] == pytest.approx(12.58)
        assert result["ask"] == pytest.approx(12.62)
        assert result["fiftyDayAverage"] == pytest.approx(11.8)
        assert result["twoHundredDayAverage"] == pytest.approx(11.05)
        assert result["fiftyTwoWeekLow"] == pytest.approx(9.8)
        assert result["fiftyTwoWeekHigh"] == pytest.approx(12.9)
        assert result["priceToBook"] == pytest.approx(12.6 / 2.852)
        assert result["priceEpsCurrentYear"] == pytest.approx(12.6 / 0.78033)
        assert result["trailingPE"] == pytest.approx(12.6 / 0.99)
        assert result["forwardPE"] == pytest.approx(12.6 / 1.00582)
        assert result["_unit_normalization"]["kind"] == "quote_minor_to_major"
        assert result["_unit_normalization"]["scale_factor"] == 0.01
        assert result["_unit_normalization"]["from_currency"] == "GBp"
        assert result["_unit_normalization"]["to_currency"] == "GBP"
        assert (
            result["_unit_normalization"]["reason"]
            == "triangle_verified_quote_unit_mismatch"
        )
        assert "currency_alias:GBp" in result["_unit_normalization"]["evidence"]
        assert "financial_currency_match" in result["_unit_normalization"]["evidence"]
        assert result["_sources_used"] == ["yfinance"]
