"""
Unit tests for fallback, parameter adaptation, and slow-function protection.

All tests use mocks — no network calls required.
"""

import sys
from unittest.mock import MagicMock, patch

import pandas as pd
import pytest
from click.testing import CliRunner

from akshare_cli.core.fallback import (
    FALLBACK_MAP,
    PARAM_ADAPTERS,
    SINGLE_STOCK_REDIRECT,
    SLOW_FUNCTIONS,
    _adapt_gdfx_symbol,
    warn_if_slow,
)
from akshare_cli.core.registry import DISABLED_FUNCTIONS


# ─── DISABLED_FUNCTIONS Tests ────────────────────────────────────────────────


class TestDisabledFunctions:
    def test_only_permanently_removed(self):
        """DISABLED_FUNCTIONS should only contain truly removed functions."""
        assert "index_news_sentiment_scope" in DISABLED_FUNCTIONS

    def test_broken_functions_not_disabled(self):
        """Temporarily broken functions should NOT be disabled (they use runtime fallback)."""
        should_not_be_disabled = {
            "stock_financial_analysis_indicator_em",
            "stock_zh_a_spot_em",
            "stock_sh_a_spot_em",
            "stock_us_spot_em",
            "fund_etf_spot_em",
        }
        for name in should_not_be_disabled:
            assert name not in DISABLED_FUNCTIONS, (
                f"{name} should use runtime fallback, not be disabled"
            )

    def test_param_adapted_functions_not_disabled(self):
        """Functions with param adapters should NOT be in DISABLED."""
        for name in PARAM_ADAPTERS:
            assert name not in DISABLED_FUNCTIONS, (
                f"{name} has a param adapter but is also disabled"
            )


# ─── PARAM_ADAPTERS Tests ───────────────────────────────────────────────────


class TestParamAdapters:
    def test_lhb_detail_em_drops_symbol(self):
        adapter = PARAM_ADAPTERS["stock_lhb_detail_em"]
        result = adapter({"symbol": "600519", "start_date": "20260101", "end_date": "20260314"})
        assert "symbol" not in result
        assert result["start_date"] == "20260101"

    def test_circulate_stock_holder_renames_stock(self):
        adapter = PARAM_ADAPTERS["stock_circulate_stock_holder"]
        result = adapter({"stock": "600519"})
        assert "stock" not in result
        assert result["symbol"] == "600519"

    def test_circulate_stock_holder_no_stock_key(self):
        adapter = PARAM_ADAPTERS["stock_circulate_stock_holder"]
        result = adapter({"symbol": "600519"})
        assert result["symbol"] == "600519"

    def test_hold_management_detail_em_drops_all(self):
        adapter = PARAM_ADAPTERS["stock_hold_management_detail_em"]
        result = adapter({"symbol": "600519", "date": "20260101"})
        assert result == {}

    def test_gdfx_top_10_em_adds_prefix_sh(self):
        result = _adapt_gdfx_symbol({"symbol": "600519", "date": "20260101"})
        assert result["symbol"] == "sh600519"
        assert result["date"] == "20260101"

    def test_gdfx_top_10_em_adds_prefix_sz(self):
        result = _adapt_gdfx_symbol({"symbol": "000001", "date": "20260101"})
        assert result["symbol"] == "sz000001"

    def test_gdfx_top_10_em_adds_prefix_bj(self):
        result = _adapt_gdfx_symbol({"symbol": "430047", "date": "20260101"})
        assert result["symbol"] == "bj430047"

    def test_gdfx_preserves_prefixed_symbol(self):
        result = _adapt_gdfx_symbol({"symbol": "sh600519", "date": "20260101"})
        assert result["symbol"] == "sh600519"


# ─── FALLBACK_MAP Tests ─────────────────────────────────────────────────────


class TestFallbackMap:
    def test_fallback_entries_are_tuples(self):
        for name, chain in FALLBACK_MAP.items():
            assert isinstance(chain, list), f"{name} fallback is not a list"
            for entry in chain:
                assert isinstance(entry, tuple) and len(entry) == 2, (
                    f"{name} fallback entry is not a 2-tuple: {entry}"
                )
                alt_name, transformer = entry
                assert isinstance(alt_name, str)
                assert callable(transformer)

    def test_financial_fallback_chain(self):
        chain = FALLBACK_MAP["stock_financial_analysis_indicator_em"]
        alt_names = [t[0] for t in chain]
        assert "stock_financial_analysis_indicator" in alt_names

    def test_us_stock_fallback(self):
        chain = FALLBACK_MAP["stock_us_hist"]
        assert chain[0][0] == "stock_us_daily"

    def test_spot_em_has_no_fallback(self):
        """Full-market spot functions must NOT fallback to other full-market APIs."""
        for name in ("stock_zh_a_spot_em", "stock_sh_a_spot_em", "stock_hk_spot_em"):
            assert name not in FALLBACK_MAP, f"{name} should not have a fallback to full-market API"

    def test_fallback_count(self):
        assert len(FALLBACK_MAP) >= 15


# ─── SLOW_FUNCTIONS Tests ───────────────────────────────────────────────────


class TestSlowFunctions:
    def test_known_slow_functions_present(self):
        assert "stock_zh_a_spot" in SLOW_FUNCTIONS

    def test_warn_if_slow_prints_warning(self, capsys):
        warn_if_slow("stock_zh_a_spot")
        captured = capsys.readouterr()
        assert "Warning" in captured.err
        assert "stock_zh_a_spot" in captured.err

    def test_warn_if_slow_no_warning_for_fast(self, capsys):
        warn_if_slow("stock_zh_a_hist")
        captured = capsys.readouterr()
        assert captured.err == ""


# ─── SINGLE_STOCK_REDIRECT Tests ────────────────────────────────────────────


class TestSingleStockRedirect:
    def test_empty_when_no_fast_targets(self):
        """No redirects when no suitable single-stock fast API exists."""
        assert len(SINGLE_STOCK_REDIRECT) == 0


# ─── Integration: call_function with runtime fallback ────────────────────────


# ─── Retry Tests ─────────────────────────────────────────────────────────────


class TestRetry:
    """Test retry logic with exponential backoff."""

    def setup_method(self):
        """Reset retry config and clear cache before each test."""
        from akshare_cli.core.registry import set_retry
        from akshare_cli.core.cache import _result_cache
        set_retry(0)
        _result_cache.clear()

    def teardown_method(self):
        """Reset retry config after each test."""
        from akshare_cli.core.registry import set_retry
        set_retry(0)

    @patch("akshare_cli.core.registry.time.sleep")
    @patch("akshare_cli.core.registry.get_function")
    @patch("akshare_cli.core.registry.get_function_info")
    def test_retry_succeeds_on_second_attempt(self, mock_info, mock_get_func, mock_sleep, capsys):
        """ConnectionError on first call, success on second."""
        from akshare_cli.core.registry import _invoke_function, set_retry
        set_retry(2)

        mock_df = pd.DataFrame({"a": [1]})
        mock_func = MagicMock(side_effect=[ConnectionError("refused"), mock_df])
        mock_get_func.return_value = mock_func
        mock_info.return_value = {
            "name": "test_func",
            "params": [],
            "docstring": "",
            "module": "akshare",
        }

        result = _invoke_function("test_func", {})
        assert result.equals(mock_df)
        assert mock_func.call_count == 2
        mock_sleep.assert_called_once_with(1.0)  # base_delay * 2^0

        captured = capsys.readouterr()
        assert "Retry 1/2" in captured.err

    @patch("akshare_cli.core.registry.time.sleep")
    @patch("akshare_cli.core.registry.get_function")
    @patch("akshare_cli.core.registry.get_function_info")
    def test_retry_exponential_backoff(self, mock_info, mock_get_func, mock_sleep):
        """Verify delays are 1s, 2s, 4s (exponential)."""
        from akshare_cli.core.registry import _invoke_function, set_retry
        set_retry(3)

        mock_df = pd.DataFrame({"a": [1]})
        mock_func = MagicMock(side_effect=[
            ConnectionError("1"), ConnectionError("2"), ConnectionError("3"), mock_df
        ])
        mock_get_func.return_value = mock_func
        mock_info.return_value = {
            "name": "test_func", "params": [], "docstring": "", "module": "akshare",
        }

        result = _invoke_function("test_func", {})
        assert result.equals(mock_df)
        assert mock_func.call_count == 4
        assert mock_sleep.call_args_list == [
            ((1.0,),), ((2.0,),), ((4.0,),),
        ]

    @patch("akshare_cli.core.registry.time.sleep")
    @patch("akshare_cli.core.registry.get_function")
    @patch("akshare_cli.core.registry.get_function_info")
    def test_retry_exhausted_raises(self, mock_info, mock_get_func, mock_sleep):
        """All retries fail — raise the last error."""
        from akshare_cli.core.registry import _invoke_function, set_retry
        set_retry(2)

        mock_func = MagicMock(side_effect=ConnectionError("always fails"))
        mock_get_func.return_value = mock_func
        mock_info.return_value = {
            "name": "test_func", "params": [], "docstring": "", "module": "akshare",
        }

        with pytest.raises(ConnectionError, match="always fails"):
            _invoke_function("test_func", {})
        assert mock_func.call_count == 3  # initial + 2 retries

    @patch("akshare_cli.core.registry.get_function")
    @patch("akshare_cli.core.registry.get_function_info")
    def test_no_retry_on_non_network_error(self, mock_info, mock_get_func):
        """Non-retryable errors (TypeError, KeyError) should not retry."""
        from akshare_cli.core.registry import _invoke_function, set_retry
        set_retry(3)

        mock_func = MagicMock(side_effect=TypeError("bad args"))
        mock_get_func.return_value = mock_func
        mock_info.return_value = {
            "name": "test_func", "params": [], "docstring": "", "module": "akshare",
        }

        with pytest.raises(TypeError, match="bad args"):
            _invoke_function("test_func", {})
        assert mock_func.call_count == 1  # no retry

    @patch("akshare_cli.core.registry.get_function")
    @patch("akshare_cli.core.registry.get_function_info")
    def test_zero_retries_no_retry(self, mock_info, mock_get_func):
        """With retries=0, errors propagate immediately."""
        from akshare_cli.core.registry import _invoke_function, set_retry
        set_retry(0)

        mock_func = MagicMock(side_effect=ConnectionError("fail"))
        mock_get_func.return_value = mock_func
        mock_info.return_value = {
            "name": "test_func", "params": [], "docstring": "", "module": "akshare",
        }

        with pytest.raises(ConnectionError):
            _invoke_function("test_func", {})
        assert mock_func.call_count == 1


class TestCallFunctionFallback:
    """Test that call_function tries primary first, then falls back on error."""

    @patch("akshare_cli.core.registry._invoke_function")
    def test_primary_success_no_fallback(self, mock_invoke):
        """When primary succeeds, no fallback is attempted."""
        mock_df = pd.DataFrame({"a": [1, 2, 3]})
        mock_invoke.return_value = mock_df

        from akshare_cli.core.registry import call_function
        result = call_function("stock_financial_analysis_indicator_em", {"symbol": "600519"})

        assert result.equals(mock_df)
        mock_invoke.assert_called_once_with("stock_financial_analysis_indicator_em", {"symbol": "600519"})

    @patch("akshare_cli.core.registry._invoke_function")
    def test_fallback_on_runtime_error(self, mock_invoke, capsys):
        """When primary raises ConnectionError, fallback is used."""
        mock_df = pd.DataFrame({"a": [1, 2, 3]})

        def side_effect(name, kwargs):
            if name == "stock_financial_analysis_indicator_em":
                raise ConnectionError("Remote end closed connection")
            return mock_df

        mock_invoke.side_effect = side_effect

        from akshare_cli.core.registry import call_function
        result = call_function("stock_financial_analysis_indicator_em", {"symbol": "600519"})

        assert result.equals(mock_df)
        captured = capsys.readouterr()
        assert "failed" in captured.err
        assert "stock_financial_analysis_indicator" in captured.err

    @patch("akshare_cli.core.registry._invoke_function")
    def test_fallback_chain_exhausted_raises_original(self, mock_invoke):
        """When primary and all fallbacks fail, raise the primary error."""
        mock_invoke.side_effect = ConnectionError("Remote end closed")

        from akshare_cli.core.registry import call_function
        with pytest.raises(ConnectionError, match="Remote end closed"):
            call_function("stock_financial_analysis_indicator_em", {"symbol": "600519"})

    @patch("akshare_cli.core.registry._invoke_function")
    def test_no_fallback_for_spot_em(self, mock_invoke):
        """Full-market spot functions should NOT fallback — error propagates directly."""
        mock_invoke.side_effect = ConnectionError("Remote end closed")

        from akshare_cli.core.registry import call_function
        with pytest.raises(ConnectionError, match="Remote end closed"):
            call_function("stock_zh_a_spot_em", {})

    @patch("akshare_cli.core.registry._invoke_function")
    def test_fallback_on_not_found(self, mock_invoke):
        """When primary is not found (ValueError), fallback is attempted."""
        mock_df = pd.DataFrame({"a": [1]})

        def side_effect(name, kwargs):
            if name == "stock_financial_analysis_indicator_em":
                raise ValueError("not found in akshare")
            return mock_df

        mock_invoke.side_effect = side_effect

        from akshare_cli.core.registry import call_function
        result = call_function("stock_financial_analysis_indicator_em", {"symbol": "600519"})
        assert result.equals(mock_df)

    @patch("akshare_cli.core.registry._invoke_function")
    def test_no_fallback_map_raises(self, mock_invoke):
        """When function has no fallback map and fails, error propagates."""
        mock_invoke.side_effect = ConnectionError("timeout")

        from akshare_cli.core.registry import call_function
        with pytest.raises(ConnectionError, match="timeout"):
            call_function("some_random_function", {})

    @patch("akshare_cli.core.registry.get_function")
    @patch("akshare_cli.core.registry.get_function_info")
    def test_param_adapter_applied(self, mock_info, mock_get_func):
        """PARAM_ADAPTERS should transform kwargs before calling."""
        mock_df = pd.DataFrame({"a": [1]})
        mock_func = MagicMock(return_value=mock_df)
        mock_get_func.return_value = mock_func
        mock_info.return_value = {
            "name": "stock_hold_management_detail_em",
            "params": [],
            "docstring": "",
            "module": "akshare",
        }

        from akshare_cli.core.registry import call_function
        call_function("stock_hold_management_detail_em", {"symbol": "600519"})

        # Param adapter drops all kwargs
        mock_func.assert_called_once_with()


# ─── CLI Commands Tests ──────────────────────────────────────────────────────


class TestNewCLICommands:
    """Test new CLI commands using CliRunner with mocked call_function."""

    @patch("akshare_cli.cli.call_function")
    def test_stock_info_command(self, mock_call):
        mock_call.return_value = pd.DataFrame({"item": ["总市值"], "value": ["1000亿"]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["stock", "info", "600519", "--json"])
        assert result.exit_code == 0
        mock_call.assert_called_once_with("stock_individual_info_em", {"symbol": "600519"})

    @patch("akshare_cli.cli.call_function")
    def test_stock_flow_command(self, mock_call):
        mock_call.return_value = pd.DataFrame({"date": ["2026-01-01"], "flow": [100]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["stock", "flow", "600519", "--json"])
        assert result.exit_code == 0

    @patch("akshare_cli.cli.call_function")
    def test_stock_finance_command(self, mock_call):
        mock_call.return_value = pd.DataFrame({"metric": ["ROE"], "value": [0.15]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["stock", "finance", "600519", "--json"])
        assert result.exit_code == 0
        mock_call.assert_called_once_with(
            "stock_financial_analysis_indicator_em", {"symbol": "600519"}
        )

    @patch("akshare_cli.cli.call_function")
    def test_stock_finance_profit(self, mock_call):
        mock_call.return_value = pd.DataFrame({"revenue": [100]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["stock", "finance", "600519", "--type", "profit", "--json"])
        assert result.exit_code == 0
        mock_call.assert_called_once_with(
            "stock_profit_sheet_by_report_em", {"symbol": "600519"}
        )

    @patch("akshare_cli.cli.call_function")
    def test_stock_holders_command(self, mock_call):
        mock_call.return_value = pd.DataFrame({"holder": ["基金A"], "shares": [1000000]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["stock", "holders", "600519", "--json"])
        assert result.exit_code == 0

    @patch("akshare_cli.cli.call_function")
    def test_stock_lhb_command(self, mock_call):
        mock_call.return_value = pd.DataFrame({"stock": ["600519"], "times": [3]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["stock", "lhb", "--json"])
        assert result.exit_code == 0

    @patch("akshare_cli.cli.call_function")
    def test_sector_concept_command(self, mock_call):
        mock_call.return_value = pd.DataFrame({"板块名称": ["AI"], "涨跌幅": [2.5]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["sector", "concept", "--json"])
        assert result.exit_code == 0
        mock_call.assert_called_once_with("stock_board_concept_name_em", {})

    @patch("akshare_cli.cli.call_function")
    def test_sector_industry_command(self, mock_call):
        mock_call.return_value = pd.DataFrame({"板块名称": ["银行"], "涨跌幅": [1.2]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["sector", "industry", "--json"])
        assert result.exit_code == 0
        mock_call.assert_called_once_with("stock_board_industry_name_em", {})

    @patch("akshare_cli.cli.call_function")
    def test_stock_spot_with_symbol(self, mock_call):
        mock_call.return_value = pd.DataFrame({"item": ["总市值"], "value": ["1000亿"]})
        from akshare_cli.cli import cli
        runner = CliRunner()
        result = runner.invoke(cli, ["stock", "spot", "--symbol", "600519", "--json"])
        assert result.exit_code == 0
        mock_call.assert_called_once_with("stock_individual_info_em", {"symbol": "600519"})
