"""Security and robustness tests covering findings from the v2 review.

Covers: SEC-1 (load_model warning), SEC-2 (cache_path traversal), SEC-4
(min_samples_per_class bounds), SEC-5 (augmentation_factor bounds), SQL-1
(proc_name validation), SSRF prevention, ML-1 (calibration fallback),
ML-3 (random_state plumbing), and TEST-1 regression coverage.
"""
import os
import warnings
import tempfile
import logging
import pytest
import numpy as np
import pandas as pd


# ---------------------------------------------------------------------------
# SEC-1 / DOC-1: load_model emits a security WARNING log at call time
# ---------------------------------------------------------------------------

class TestLoadModelSecurityWarning:

    def test_load_model_logs_warning(self, tmp_path, caplog):
        """load_model() must emit a logging.WARNING before deserialising."""
        from pwml.classifiers.features import InputFeature, OutputFeature
        from pwml.classifiers.hierarchical import HierarchicalClassifierModel

        # Build a minimal trained model
        rng = np.random.default_rng(0)
        df = pd.DataFrame({
            'x': rng.uniform(0, 1, 60),
            'label': ['a'] * 30 + ['b'] * 30,
        })
        model = HierarchicalClassifierModel(
            model_name='sec1_test',
            experiment_name='test',
            input_features=[InputFeature('x', 'numeric')],
            output_feature_hierarchy=OutputFeature('label'),
            cache_path=str(tmp_path),
        )
        model.load_from_dataframe(df)
        model_path = str(tmp_path / 'model.joblib')
        model.save_model(model_path)

        with caplog.at_level(logging.WARNING, logger='pwml.classifiers.hierarchical'):
            HierarchicalClassifierModel.load_model(model_path)

        warning_msgs = [r.message for r in caplog.records if r.levelno == logging.WARNING]
        assert any('trusted' in str(m).lower() or 'joblib' in str(m).lower()
                   for m in warning_msgs), \
            "Expected a security warning about joblib.load() in load_model()"


# ---------------------------------------------------------------------------
# SEC-2: cache_path directory traversal is rejected
# ---------------------------------------------------------------------------

class TestCachePathTraversal:

    def test_path_outside_cache_root_raises(self, tmp_path, monkeypatch):
        """When PWML_CACHE_ROOT is set, cache_path outside it must raise ValueError."""
        from pwml.classifiers.embedders import CategoryEmbedder

        allowed = str(tmp_path / 'allowed')
        os.makedirs(allowed, exist_ok=True)
        outside = str(tmp_path / 'outside')
        os.makedirs(outside, exist_ok=True)

        monkeypatch.setenv('PWML_CACHE_ROOT', allowed)

        with pytest.raises(ValueError, match='outside the allowed root'):
            CategoryEmbedder(cache_path=outside)

    def test_traversal_outside_cache_root_raises(self, tmp_path, monkeypatch):
        """.. traversal that resolves outside PWML_CACHE_ROOT must raise."""
        from pwml.classifiers.embedders import CategoryEmbedder

        allowed = str(tmp_path / 'allowed')
        os.makedirs(allowed, exist_ok=True)
        # Traversal: allowed/../outside resolves to tmp_path/outside
        traversal = str(tmp_path / 'allowed' / '..' / 'outside')
        monkeypatch.setenv('PWML_CACHE_ROOT', allowed)

        with pytest.raises(ValueError, match='outside the allowed root'):
            CategoryEmbedder(cache_path=traversal)

    def test_valid_path_inside_root_works(self, tmp_path, monkeypatch):
        """A cache_path inside PWML_CACHE_ROOT must NOT raise."""
        from pwml.classifiers.embedders import CategoryEmbedder

        root = str(tmp_path)
        sub = str(tmp_path / 'cache')
        os.makedirs(sub, exist_ok=True)
        monkeypatch.setenv('PWML_CACHE_ROOT', root)

        embedder = CategoryEmbedder(cache_path=sub)
        embedder.close()

    def test_no_cache_root_env_allows_any_path(self, tmp_path, monkeypatch):
        """Without PWML_CACHE_ROOT set, any absolute path is accepted."""
        from pwml.classifiers.embedders import CategoryEmbedder

        monkeypatch.delenv('PWML_CACHE_ROOT', raising=False)

        # tmp_path may be /private/var/... on macOS; must work without env var
        embedder = CategoryEmbedder(cache_path=str(tmp_path))
        embedder.close()


# ---------------------------------------------------------------------------
# SEC-4: set_min_samples_per_class bounds validation
# ---------------------------------------------------------------------------

class TestMinSamplesPerClassBounds:

    def _make_element(self):
        from pwml.classifiers.hierarchical import HierarchyElement
        elem = HierarchyElement()
        elem.classes = ['a', 'b']
        elem.data = {}
        return elem

    def test_zero_raises(self):
        elem = self._make_element()
        with pytest.raises(ValueError, match='must be >= 1'):
            elem.set_min_samples_per_class(0)

    def test_negative_raises(self):
        elem = self._make_element()
        with pytest.raises(ValueError, match='must be >= 1'):
            elem.set_min_samples_per_class(-5)

    def test_too_large_raises(self):
        elem = self._make_element()
        with pytest.raises(ValueError, match='suspiciously large'):
            elem.set_min_samples_per_class(200_000)

    def test_valid_value_does_not_raise(self):
        elem = self._make_element()
        # No data, so no-op - but must not raise on valid input
        elem.set_min_samples_per_class(10)


# ---------------------------------------------------------------------------
# SEC-5: augmentation_factor bounds validation
# ---------------------------------------------------------------------------

class TestAugmentationFactorBounds:

    def _make_data(self):
        rng = np.random.default_rng(0)
        n = 30
        df = pd.DataFrame({
            'x': rng.uniform(0, 1, n),
            'y': rng.uniform(0, 1, n),
        })
        return df

    def test_negative_augmentation_factor_raises(self, tmp_path):
        from pwml.timeseries.dataaugmentationhelpers import prepare_data

        df = self._make_data()
        with pytest.raises(ValueError, match='augmentation_factor must be >= 0'):
            prepare_data(
                data=df,
                lags_in=None,
                cols_in=['x'],
                steps_in=3,
                cols_out=['y'],
                steps_out=1,
                augmentation_factor=-1,
                verbose=0)

    def test_huge_augmentation_factor_raises(self, tmp_path):
        from pwml.timeseries.dataaugmentationhelpers import prepare_data

        df = self._make_data()
        with pytest.raises(ValueError, match='suspiciously large'):
            prepare_data(
                data=df,
                lags_in=None,
                cols_in=['x'],
                steps_in=3,
                cols_out=['y'],
                steps_out=1,
                augmentation_factor=20_000,
                verbose=0)

    def test_valid_augmentation_factor_works(self, tmp_path):
        from pwml.timeseries.dataaugmentationhelpers import prepare_data

        df = self._make_data()
        X, y, idx, si, so, samples = prepare_data(
            data=df,
            lags_in=None,
            cols_in=['x'],
            steps_in=3,
            cols_out=['y'],
            steps_out=1,
            augmentation_factor=2,
            verbose=0)
        assert X.shape[0] == samples * 3


# ---------------------------------------------------------------------------
# SQL-1: proc_name validation
# ---------------------------------------------------------------------------

class TestProcNameValidation:

    def test_invalid_proc_name_raises(self):
        """proc_name with special characters must raise ValueError immediately."""
        from pwml.utilities import mssqlhelpers as ms

        with pytest.raises(ValueError, match='invalid characters'):
            ms.execute("dbo.MyProc'; DROP TABLE--", conn_params=(), proc_params=())

    def test_sql_injection_attempt_raises(self):
        """SQL injection in proc_name must be blocked."""
        from pwml.utilities import mssqlhelpers as ms

        with pytest.raises(ValueError, match='invalid characters'):
            ms.execute("dbo.Proc; EXEC xp_cmdshell('whoami')",
                       conn_params=(), proc_params=())


# ---------------------------------------------------------------------------
# SSRF: get_url rejects non-http schemes
# ---------------------------------------------------------------------------

class TestSSRFPrevention:

    def test_file_scheme_raises(self):
        from pwml.utilities.httphelpers import get_url
        with pytest.raises(ValueError, match="Unsupported URL scheme"):
            get_url('file:///etc/passwd')

    def test_ftp_scheme_raises(self):
        from pwml.utilities.httphelpers import get_url
        with pytest.raises(ValueError, match="Unsupported URL scheme"):
            get_url('ftp://example.com/file')

    def test_gopher_scheme_raises(self):
        from pwml.utilities.httphelpers import get_url
        with pytest.raises(ValueError, match="Unsupported URL scheme"):
            get_url('gopher://evil.com')


# ---------------------------------------------------------------------------
# ML-1: Calibration fallback does NOT use training set (no leakage)
# ---------------------------------------------------------------------------

class TestCalibrationFallback:

    def test_fallback_uses_default_thresholds(self):
        """When the dataset is too small for stratified split, fit() must return
        a model with default thresholds (0.5) and NOT calibrate on training data."""
        from sklearn.dummy import DummyClassifier
        from pwml.utilities.classificationhelpers import (
            MulticlassClassifierOptimizer, BinaryClassifierHelper)

        # 4 samples, 2 classes: too small for 80/20 stratified split
        X = np.array([[0.1], [0.2], [0.3], [0.4]])
        y = np.array([0, 1, 0, 1])

        model = DummyClassifier(strategy='most_frequent')
        optimiser = MulticlassClassifierOptimizer(
            model=model,
            classes=[0, 1],
            scoring_function=BinaryClassifierHelper.f1_score)

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter('always')
            optimiser.fit(X, y)

        user_warnings = [w for w in caught
                         if issubclass(w.category, UserWarning)]
        assert len(user_warnings) >= 1
        assert 'Calibration split failed' in str(user_warnings[0].message)

        # Thresholds must be default (0.5) - not optimised on training data
        assert optimiser.thresholds is not None
        np.testing.assert_allclose(optimiser.thresholds, 0.5, atol=1e-9)

    def test_normal_dataset_produces_optimised_thresholds(self, tmp_path):
        """A sufficiently large dataset must run full calibration + optimisation."""
        from sklearn.dummy import DummyClassifier
        from pwml.utilities.classificationhelpers import (
            MulticlassClassifierOptimizer, BinaryClassifierHelper)

        rng = np.random.default_rng(0)
        X = rng.uniform(0, 1, (200, 3))
        y = np.array([0] * 100 + [1] * 100)

        model = DummyClassifier(strategy='most_frequent')
        optimiser = MulticlassClassifierOptimizer(
            model=model,
            classes=[0, 1],
            scoring_function=BinaryClassifierHelper.f1_score)

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter('always')
            optimiser.fit(X, y)

        user_warnings = [w for w in caught
                         if issubclass(w.category, UserWarning)
                         and 'Calibration split failed' in str(w.message)]
        assert len(user_warnings) == 0, "Fallback warning should NOT fire on large data"


# ---------------------------------------------------------------------------
# ML-3: random_state is plumbed through HierarchicalClassifierModel
# ---------------------------------------------------------------------------

class TestRandomStatePlumbing:

    def _make_df(self):
        rng = np.random.default_rng(0)
        return pd.DataFrame({
            'x': rng.uniform(0, 1, 60),
            'label': ['a'] * 30 + ['b'] * 30,
        })

    def test_random_state_stored_and_serialised(self, tmp_path):
        """random_state must be saved and restored via save/load round-trip."""
        from pwml.classifiers.features import InputFeature, OutputFeature
        from pwml.classifiers.hierarchical import HierarchicalClassifierModel

        df = self._make_df()
        model = HierarchicalClassifierModel(
            model_name='rs_test',
            experiment_name='test',
            input_features=[InputFeature('x', 'numeric')],
            output_feature_hierarchy=OutputFeature('label'),
            cache_path=str(tmp_path),
            random_state=42,
        )
        model.load_from_dataframe(df)
        assert model.random_state == 42

        model_path = str(tmp_path / 'model.joblib')
        model.save_model(model_path)

        with warnings.catch_warnings(record=True):
            warnings.simplefilter('always')
            loaded = HierarchicalClassifierModel.load_model(model_path)

        assert loaded.random_state == 42

    def test_different_seeds_accepted(self, tmp_path):
        """tune_classifiers must accept an explicit random_state override."""
        from pwml.classifiers.features import InputFeature, OutputFeature
        from pwml.classifiers.hierarchical import HierarchicalClassifierModel

        df = self._make_df()
        model = HierarchicalClassifierModel(
            model_name='rs_test2',
            experiment_name='test',
            input_features=[InputFeature('x', 'numeric')],
            output_feature_hierarchy=OutputFeature('label'),
            cache_path=str(tmp_path),
        )
        model.load_from_dataframe(df, tune=False)
        # Should not raise - random_state is passed down
        model.tune_classifiers(random_state=7)
