"""Unit tests for services.signal_engine.config.

Covers:
- Default values and fail-safe behaviour
- DB row parsing and application
- Environment variable overrides
- Sub-config derivation properties
- load_config() with mocked asyncpg pool
"""

from __future__ import annotations

import json
import os
from unittest.mock import AsyncMock, patch

import pytest

from services.signal_engine.config import (
    ExitConfig,
    HardFilterConfig,
    HeuristicConfig,
    ProbabilisticConfig,
    SignalEngineConfig,
    _apply_db_rows,
    _apply_env_overrides,
    _parse_value,
    load_config,
)

# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------


class TestDefaults:
    """SignalEngineConfig defaults match the design spec."""

    def test_dual_pipeline_disabled_by_default(self):
        cfg = SignalEngineConfig()
        assert cfg.dual_pipeline_enabled is False

    def test_both_pipelines_enabled_by_default(self):
        cfg = SignalEngineConfig()
        assert cfg.heuristic_pipeline_enabled is True
        assert cfg.probabilistic_pipeline_enabled is True

    def test_shadow_mode_off_by_default(self):
        cfg = SignalEngineConfig()
        assert cfg.shadow_mode is False

    def test_timeframe_weights_default(self):
        cfg = SignalEngineConfig()
        expected = {
            "M30": 0.03,
            "H1": 0.07,
            "H4": 0.15,
            "D": 0.30,
            "W": 0.30,
            "M": 0.15,
        }
        assert cfg.timeframe_weights == expected

    def test_hard_filter_defaults(self):
        cfg = SignalEngineConfig()
        assert cfg.hard_filter_valuation_min == 0.3
        assert cfg.hard_filter_earnings_days == 5
        assert cfg.hard_filter_macro_bias_skip == -1.0

    def test_heuristic_threshold_defaults(self):
        cfg = SignalEngineConfig()
        assert cfg.heuristic_buy_confidence == 0.70
        assert cfg.heuristic_buy_s_total == 1.2
        assert cfg.heuristic_buy_valuation_min == 0.5
        assert cfg.heuristic_watch_confidence == 0.55

    def test_probabilistic_threshold_defaults(self):
        cfg = SignalEngineConfig()
        assert cfg.prob_buy_p_up == 0.60
        assert cfg.prob_buy_entropy_max == 0.90
        assert cfg.prob_buy_ev_r_min == 1.5
        assert cfg.prob_buy_valuation_min == 0.5
        assert cfg.prob_watch_p_up == 0.55
        assert cfg.prob_watch_entropy_max == 0.95
        assert cfg.prob_entropy_skip == 0.95

    def test_regime_prior_defaults(self):
        cfg = SignalEngineConfig()
        assert cfg.regime_prior_bull == 0.58
        assert cfg.regime_prior_range == 0.50
        assert cfg.regime_prior_bear == 0.42

    def test_exit_and_polling_defaults(self):
        cfg = SignalEngineConfig()
        assert cfg.trailing_stop_atr_multiplier == 2.0
        assert cfg.polling_interval_seconds == 30


# ---------------------------------------------------------------------------
# Sub-config derivation
# ---------------------------------------------------------------------------


class TestSubConfigs:
    """Properties derive correct sub-config instances."""

    def test_hard_filter_config(self):
        cfg = SignalEngineConfig(
            hard_filter_valuation_min=0.4,
            hard_filter_earnings_days=7,
            hard_filter_macro_bias_skip=-0.5,
        )
        hf = cfg.hard_filter_config
        assert isinstance(hf, HardFilterConfig)
        assert hf.valuation_min == 0.4
        assert hf.earnings_days == 7
        assert hf.macro_bias_skip == -0.5

    def test_heuristic_config(self):
        cfg = SignalEngineConfig(
            heuristic_buy_confidence=0.80,
            heuristic_buy_s_total=1.5,
            heuristic_buy_valuation_min=0.6,
            heuristic_watch_confidence=0.60,
            hard_filter_earnings_days=10,
        )
        hc = cfg.heuristic_config
        assert isinstance(hc, HeuristicConfig)
        assert hc.buy_confidence == 0.80
        assert hc.buy_s_total == 1.5
        assert hc.buy_valuation_min == 0.6
        assert hc.watch_confidence == 0.60
        assert hc.macro_bias_threshold == 0.0
        assert hc.earnings_days_threshold == 10

    def test_probabilistic_config(self):
        cfg = SignalEngineConfig(
            prob_buy_p_up=0.65,
            regime_prior_bull=0.60,
        )
        pc = cfg.probabilistic_config
        assert isinstance(pc, ProbabilisticConfig)
        assert pc.buy_p_up == 0.65
        assert pc.regime_prior_bull == 0.60
        assert pc.macro_bias_threshold == 0.0

    def test_exit_config(self):
        cfg = SignalEngineConfig(trailing_stop_atr_multiplier=3.0)
        ec = cfg.exit_config
        assert isinstance(ec, ExitConfig)
        assert ec.trailing_stop_atr_multiplier == 3.0


# ---------------------------------------------------------------------------
# _parse_value
# ---------------------------------------------------------------------------


class TestParseValue:
    def test_bool_true_variants(self):
        for v in ("true", "True", "TRUE", "1", "yes"):
            assert _parse_value(v, bool) is True

    def test_bool_false_variants(self):
        for v in ("false", "False", "0", "no", "anything"):
            assert _parse_value(v, bool) is False

    def test_int(self):
        assert _parse_value("42", int) == 42

    def test_float(self):
        assert _parse_value("0.75", float) == 0.75

    def test_dict_json(self):
        raw = json.dumps({"D": 0.30, "W": 0.30})
        result = _parse_value(raw, dict)
        assert result == {"D": 0.30, "W": 0.30}

    def test_invalid_int_raises(self):
        with pytest.raises(ValueError):
            _parse_value("not_a_number", int)

    def test_invalid_json_raises(self):
        with pytest.raises(json.JSONDecodeError):
            _parse_value("{bad json", dict)


# ---------------------------------------------------------------------------
# _apply_db_rows
# ---------------------------------------------------------------------------


class TestApplyDbRows:
    def test_applies_known_keys(self):
        cfg = SignalEngineConfig()
        rows = [
            ("signal_engine_dual_pipeline_enabled", "true"),
            ("signal_engine_prob_buy_p_up", "0.65"),
            ("signal_engine_polling_interval_seconds", "60"),
        ]
        _apply_db_rows(cfg, rows)
        assert cfg.dual_pipeline_enabled is True
        assert cfg.prob_buy_p_up == 0.65
        assert cfg.polling_interval_seconds == 60

    def test_ignores_unknown_keys(self):
        cfg = SignalEngineConfig()
        rows = [("signal_engine_unknown_field", "whatever")]
        _apply_db_rows(cfg, rows)  # should not raise

    def test_invalid_value_keeps_default(self):
        cfg = SignalEngineConfig()
        rows = [("signal_engine_hard_filter_earnings_days", "not_a_number")]
        _apply_db_rows(cfg, rows)
        assert cfg.hard_filter_earnings_days == 5  # default preserved

    def test_timeframe_weights_from_json(self):
        cfg = SignalEngineConfig()
        new_weights = {"D": 0.50, "W": 0.50}
        rows = [
            ("signal_engine_timeframe_weights", json.dumps(new_weights)),
        ]
        _apply_db_rows(cfg, rows)
        assert cfg.timeframe_weights == new_weights


# ---------------------------------------------------------------------------
# _apply_env_overrides
# ---------------------------------------------------------------------------


class TestApplyEnvOverrides:
    def test_env_override_bool(self):
        cfg = SignalEngineConfig()
        with patch.dict(os.environ, {"SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED": "true"}):
            _apply_env_overrides(cfg)
        assert cfg.dual_pipeline_enabled is True

    def test_env_override_float(self):
        cfg = SignalEngineConfig()
        with patch.dict(os.environ, {"SIGNAL_ENGINE_PROB_BUY_P_UP": "0.70"}):
            _apply_env_overrides(cfg)
        assert cfg.prob_buy_p_up == 0.70

    def test_env_override_int(self):
        cfg = SignalEngineConfig()
        with patch.dict(os.environ, {"SIGNAL_ENGINE_POLLING_INTERVAL_SECONDS": "120"}):
            _apply_env_overrides(cfg)
        assert cfg.polling_interval_seconds == 120

    def test_env_ignores_unrelated_vars(self):
        cfg = SignalEngineConfig()
        with patch.dict(os.environ, {"UNRELATED_VAR": "hello"}):
            _apply_env_overrides(cfg)
        # No change — just verifying no crash
        assert cfg.dual_pipeline_enabled is False

    def test_invalid_env_value_keeps_previous(self):
        cfg = SignalEngineConfig()
        cfg.hard_filter_earnings_days = 10
        with patch.dict(os.environ, {"SIGNAL_ENGINE_HARD_FILTER_EARNINGS_DAYS": "bad"}):
            _apply_env_overrides(cfg)
        assert cfg.hard_filter_earnings_days == 10  # unchanged


# ---------------------------------------------------------------------------
# load_config (async)
# ---------------------------------------------------------------------------


class TestLoadConfig:
    @pytest.mark.asyncio
    async def test_load_with_db_rows(self):
        """DB rows are applied over defaults."""
        pool = AsyncMock()
        pool.fetch = AsyncMock(
            return_value=[
                {"key": "signal_engine_dual_pipeline_enabled", "value": "true"},
                {"key": "signal_engine_shadow_mode", "value": "true"},
            ]
        )
        cfg = await load_config(pool)
        assert cfg.dual_pipeline_enabled is True
        assert cfg.shadow_mode is True

    @pytest.mark.asyncio
    async def test_load_with_empty_db(self):
        """Empty DB result returns safe defaults."""
        pool = AsyncMock()
        pool.fetch = AsyncMock(return_value=[])
        cfg = await load_config(pool)
        assert cfg.dual_pipeline_enabled is False
        assert cfg.heuristic_pipeline_enabled is True

    @pytest.mark.asyncio
    async def test_load_db_failure_failsafe(self):
        """DB error falls back to disabled (fail-safe)."""
        pool = AsyncMock()
        pool.fetch = AsyncMock(side_effect=Exception("connection refused"))
        cfg = await load_config(pool)
        assert cfg.dual_pipeline_enabled is False

    @pytest.mark.asyncio
    async def test_env_overrides_db_values(self):
        """Environment variables take precedence over DB values."""
        pool = AsyncMock()
        pool.fetch = AsyncMock(
            return_value=[
                {"key": "signal_engine_prob_buy_p_up", "value": "0.55"},
            ]
        )
        with patch.dict(os.environ, {"SIGNAL_ENGINE_PROB_BUY_P_UP": "0.70"}):
            cfg = await load_config(pool)
        assert cfg.prob_buy_p_up == 0.70  # env wins

    @pytest.mark.asyncio
    async def test_env_overrides_applied_after_db_failure(self):
        """Env overrides still apply even when DB read fails."""
        pool = AsyncMock()
        pool.fetch = AsyncMock(side_effect=Exception("timeout"))
        with patch.dict(
            os.environ, {"SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED": "true"}
        ):
            cfg = await load_config(pool)
        # Env override can re-enable even after DB failure
        assert cfg.dual_pipeline_enabled is True
