"""
test_config_validation.py

Covers config_loader.validate_config() -- the [FEATURE 2026-09-01] basic
sanity gate added per explicit request ("confirm all parameters are
positive... instead of being more restrictive on entry"). Deliberately
narrow in scope: only flags values that can never be legitimate (a
lookback window of 0, a negative percentage/ratio/score), never touches
entry-strategy tuning itself. See config_loader.py's
_STRICTLY_POSITIVE_PATHS comment for the exact rationale and what's
intentionally excluded (documented 0-means-disabled sentinels, and
credit/weight fields that can legitimately be 0).

Run:
    python -m pytest tests/test_config_validation.py -v
or:
    python tests/test_config_validation.py
"""

import sys
import os
import copy
import unittest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from config_loader import get_config, validate_config, ConfigValidationError


def _valid_config():
    """A minimal but complete config covering every path
    validate_config() checks, all set to legitimate values."""
    return {
        "universe": {"price_min": 5.0, "price_max": 15.0, "min_avg_daily_volume": 500000},
        "candidates": {"premarket_candidate_count": 30, "final_candidate_count": 15},
        "schedule": {
            "poll_interval_seconds_intraday": 5,
            "eod_liquidation_max_wait_seconds": 120,
        },
        "premarket_scoring": {
            "min_premarket_volume": 20000, "min_rvol": 1.5, "vwap_lookback_bars": 12,
            "momentum_lookback_snapshots": 5, "acceleration_min_ratio": 1.15,
            "weights": {"price_quality": 5, "volume_quality": 12},
        },
        "intraday_health": {
            "eval_interval_seconds": 60, "slope_lookback_bars": 8, "structure_lookback_bars": 4,
            "atr_period": 14, "healthy_confirm_reads": 2, "unhealthy_confirm_reads": 3,
            "remove_after_consecutive_unhealthy": 3, "full_rescan_interval_minutes": 30,
            "full_rescan_pool_size": 20, "full_rescan_lookback_hours": 1,
            "entry_shortlist_size": 5, "volume_participation_rvol_target": 1.5,
            "max_consecutive_confirmation_failures": 5, "confirmation_failure_cooldown_seconds": 180,
            "weights": {"price_slope": 20, "vwap_support": 20},
            # legitimately-zero credit fields -- must NOT be flagged
            "price_slope_negative_credit": 0.0,
            "vwap_support_weak_credit": 0.1,
        },
        "entry": {
            "vwap_rising_lookback_bars": 3, "opening_volume_expansion_ratio": 1.4,
            "breakout_hold_bars": 2, "higher_lows_lookback_bars": 4,
            "max_spread_pct_entry": 1.2, "max_extension_from_vwap_pct": 4.0,
            "min_confirmation_score": 65, "rsi_period": 14,
            "cancel_unfilled_after_seconds": 20,
            # legitimately-zero disabled sentinel -- must NOT be flagged
            "min_extension_from_vwap_pct": 0.0,
        },
        "risk": {
            "account_risk_pct_per_trade": 1.0, "max_position_notional_pct_of_equity": 20,
            "min_shares": 1, "max_daily_loss_pct": 3.0, "max_consecutive_losses_pause": 4,
        },
        "stop": {
            "initial_distance": 0.10, "trailing_distance": 0.10,
            "percentage_initial_pct": 1.0, "percentage_trailing_pct": 1.0,
            "atr_period": 14, "atr_multiplier_initial": 1.2, "atr_multiplier_trailing": 0.25,
            "min_stop_distance_cents": 0.03, "max_stop_distance_pct": 3.0,
        },
        "trading": {
            "max_positions": 5, "simulated_equity_default": 100000.0,
            # legitimately-zero disabled sentinel -- must NOT be flagged
            "same_symbol_reentry_cooldown_minutes": 0,
        },
    }


class TestValidateConfigAcceptsGoodConfig(unittest.TestCase):
    def test_minimal_valid_config_passes(self):
        validate_config(_valid_config())  # should not raise

    def test_real_config_json_passes(self):
        validate_config(get_config())  # should not raise

    def test_documented_zero_sentinels_are_not_flagged(self):
        cfg = _valid_config()
        self.assertEqual(cfg["intraday_health"]["price_slope_negative_credit"], 0.0)
        self.assertEqual(cfg["entry"]["min_extension_from_vwap_pct"], 0.0)
        self.assertEqual(cfg["trading"]["same_symbol_reentry_cooldown_minutes"], 0)
        validate_config(cfg)  # should not raise despite the zeros above

    def test_zero_weight_is_allowed(self):
        cfg = _valid_config()
        cfg["premarket_scoring"]["weights"]["price_quality"] = 0
        validate_config(cfg)  # a factor turned off via 0 weight is legitimate


class TestValidateConfigRejectsBadConfig(unittest.TestCase):
    def test_negative_min_confirmation_score_is_rejected(self):
        cfg = _valid_config()
        cfg["entry"]["min_confirmation_score"] = -10
        with self.assertRaises(ConfigValidationError) as ctx:
            validate_config(cfg)
        self.assertIn("entry.min_confirmation_score", str(ctx.exception))

    def test_zero_lookback_bars_is_rejected(self):
        cfg = _valid_config()
        cfg["premarket_scoring"]["vwap_lookback_bars"] = 0
        with self.assertRaises(ConfigValidationError) as ctx:
            validate_config(cfg)
        self.assertIn("premarket_scoring.vwap_lookback_bars", str(ctx.exception))

    def test_negative_atr_multiplier_is_rejected(self):
        cfg = _valid_config()
        cfg["stop"]["atr_multiplier_trailing"] = -0.25
        with self.assertRaises(ConfigValidationError) as ctx:
            validate_config(cfg)
        self.assertIn("stop.atr_multiplier_trailing", str(ctx.exception))

    def test_negative_weight_is_rejected(self):
        cfg = _valid_config()
        cfg["intraday_health"]["weights"]["price_slope"] = -5
        with self.assertRaises(ConfigValidationError) as ctx:
            validate_config(cfg)
        self.assertIn("intraday_health.weights.price_slope", str(ctx.exception))

    def test_non_numeric_value_is_rejected(self):
        cfg = _valid_config()
        cfg["trading"]["max_positions"] = "five"
        with self.assertRaises(ConfigValidationError) as ctx:
            validate_config(cfg)
        self.assertIn("trading.max_positions", str(ctx.exception))

    def test_all_problems_are_reported_together(self):
        cfg = _valid_config()
        cfg["entry"]["min_confirmation_score"] = -10
        cfg["stop"]["atr_multiplier_trailing"] = 0
        with self.assertRaises(ConfigValidationError) as ctx:
            validate_config(cfg)
        message = str(ctx.exception)
        self.assertIn("entry.min_confirmation_score", message)
        self.assertIn("stop.atr_multiplier_trailing", message)

    def test_missing_key_is_not_this_functions_job(self):
        cfg = _valid_config()
        del cfg["entry"]["min_confirmation_score"]
        validate_config(cfg)  # absent keys are a KeyError at use-site, not a validation error here


if __name__ == "__main__":
    unittest.main()
