"""
Canopy — Institutional-Grade Hierarchical Portfolio Optimization
Copyright © 2026 Anagatam Technologies. All rights reserved.

Tests for core optimization algorithms and pipeline.
"""

import numpy as np
import pandas as pd
import pytest
import sys
import os

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from canopy.MasterCanopy import MasterCanopy


# ═══════════════════════════════════════════════════════════════════════════
# TEST FIXTURES
# ═══════════════════════════════════════════════════════════════════════════

@pytest.fixture
def sample_returns():
    """Generate synthetic returns for 10 assets over 500 days."""
    np.random.seed(42)
    n_assets = 10
    n_obs = 500
    names = [f'Asset_{i}' for i in range(n_assets)]
    # Create correlated returns using a factor model
    factors = np.random.randn(n_obs, 3) * 0.01
    betas = np.random.randn(3, n_assets) * 0.5
    idio = np.random.randn(n_obs, n_assets) * 0.005
    returns = factors @ betas + idio
    return pd.DataFrame(returns, columns=names)


# ═══════════════════════════════════════════════════════════════════════════
# INITIALIZATION TESTS
# ═══════════════════════════════════════════════════════════════════════════

class TestInitialization:
    """Test constructor validation and defaults."""

    def test_default_init(self):
        opt = MasterCanopy()
        assert opt.method == 'HRP'
        assert opt.linkage_method == 'ward'
        assert opt.codependence == 'pearson'
        assert opt.max_k == 10

    def test_all_methods(self):
        for m in ['HRP', 'HERC', 'NCO']:
            opt = MasterCanopy(method=m)
            assert opt.method == m

    def test_invalid_method(self):
        with pytest.raises(ValueError, match="Unsupported method"):
            MasterCanopy(method='INVALID')

    def test_all_linkages(self):
        for lm in ['ward', 'single', 'complete', 'average', 'weighted', 'centroid', 'median']:
            opt = MasterCanopy(linkage_method=lm)
            assert opt.linkage_method == lm

    def test_invalid_linkage(self):
        with pytest.raises(ValueError, match="Unsupported linkage"):
            MasterCanopy(linkage_method='bad')

    def test_all_codependence(self):
        for cd in ['pearson', 'abs_pearson', 'spearman', 'kendall']:
            opt = MasterCanopy(codependence=cd)
            assert opt.codependence == cd

    def test_invalid_codependence(self):
        with pytest.raises(ValueError, match="Unsupported codependence"):
            MasterCanopy(codependence='gamma')

    def test_invalid_max_k(self):
        with pytest.raises(ValueError, match="max_k"):
            MasterCanopy(max_k=-1)


# ═══════════════════════════════════════════════════════════════════════════
# INPUT VALIDATION TESTS
# ═══════════════════════════════════════════════════════════════════════════

class TestInputValidation:
    """Test that invalid inputs are caught early."""

    def test_non_dataframe(self):
        opt = MasterCanopy()
        with pytest.raises(TypeError, match="Expected pd.DataFrame"):
            opt.cluster(np.array([[1, 2], [3, 4]]))

    def test_single_asset(self):
        opt = MasterCanopy()
        df = pd.DataFrame({'A': np.random.randn(100)})
        with pytest.raises(ValueError, match="at least 2 assets"):
            opt.cluster(df)

    def test_few_observations(self):
        opt = MasterCanopy()
        df = pd.DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
        with pytest.raises(ValueError, match="at least 10 observations"):
            opt.cluster(df)

    def test_nan_values(self):
        opt = MasterCanopy()
        df = pd.DataFrame({'A': [np.nan] + [0.01] * 49, 'B': [0.02] * 50})
        with pytest.raises(ValueError, match="NaN"):
            opt.cluster(df)

    def test_inf_values(self):
        opt = MasterCanopy()
        df = pd.DataFrame({'A': [np.inf] + [0.01] * 49, 'B': [0.02] * 50})
        with pytest.raises(ValueError, match="infinite"):
            opt.cluster(df)

    def test_zero_variance(self):
        opt = MasterCanopy()
        df = pd.DataFrame({'A': [0.0] * 50, 'B': [0.02] * 50})
        with pytest.raises(ValueError, match="Zero-variance"):
            opt.cluster(df)


# ═══════════════════════════════════════════════════════════════════════════
# HRP TESTS
# ═══════════════════════════════════════════════════════════════════════════

class TestHRP:
    """Test HRP algorithm correctness."""

    def test_weights_sum_to_one(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        w = opt.cluster(sample_returns).allocate()
        assert abs(w.sum() - 1.0) < 1e-10

    def test_all_positive(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        w = opt.cluster(sample_returns).allocate()
        assert (w > 0).all()

    def test_correct_index(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        w = opt.cluster(sample_returns).allocate()
        assert set(w.index) == set(sample_returns.columns)

    def test_method_chaining(self, sample_returns):
        w = MasterCanopy(method='HRP').cluster(sample_returns).allocate()
        assert abs(w.sum() - 1.0) < 1e-10

    def test_standalone_allocate(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        w = opt.allocate(sample_returns)
        assert abs(w.sum() - 1.0) < 1e-10


# ═══════════════════════════════════════════════════════════════════════════
# HERC TESTS
# ═══════════════════════════════════════════════════════════════════════════

class TestHERC:
    """Test HERC algorithm correctness."""

    def test_weights_sum_to_one(self, sample_returns):
        opt = MasterCanopy(method='HERC')
        w = opt.cluster(sample_returns).allocate()
        assert abs(w.sum() - 1.0) < 1e-10

    def test_all_positive(self, sample_returns):
        opt = MasterCanopy(method='HERC')
        w = opt.cluster(sample_returns).allocate()
        assert (w > 0).all()

    def test_cluster_detection(self, sample_returns):
        opt = MasterCanopy(method='HERC')
        opt.cluster(sample_returns)
        assert opt.num_clusters >= 2


# ═══════════════════════════════════════════════════════════════════════════
# NCO TESTS
# ═══════════════════════════════════════════════════════════════════════════

class TestNCO:
    """Test NCO algorithm correctness."""

    def test_weights_sum_to_one(self, sample_returns):
        opt = MasterCanopy(method='NCO')
        w = opt.cluster(sample_returns).allocate()
        assert abs(w.sum() - 1.0) < 1e-6

    def test_correct_assets(self, sample_returns):
        opt = MasterCanopy(method='NCO')
        w = opt.cluster(sample_returns).allocate()
        assert set(w.index) == set(sample_returns.columns)


# ═══════════════════════════════════════════════════════════════════════════
# AUDIT & SERIALIZATION TESTS
# ═══════════════════════════════════════════════════════════════════════════

class TestAudit:
    """Test audit trail and serialization."""

    def test_audit_log_populated(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        opt.cluster(sample_returns).allocate()
        assert len(opt.auditlog) >= 5  # init + validation + cov + corr + dist + link + seri + alloc

    def test_summary_string(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        opt.cluster(sample_returns).allocate()
        s = opt.summary()
        assert 'CANOPY OPTIMIZATION AUDIT REPORT' in s
        assert 'allocation' in s

    def test_todict(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        opt.cluster(sample_returns).allocate()
        d = opt.todict()
        assert d['engine'] == 'canopy'
        assert 'weights' in d
        assert 'auditlog' in d

    def test_tojson(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        opt.cluster(sample_returns).allocate()
        j = opt.tojson()
        assert '"engine": "canopy"' in j
        import json
        data = json.loads(j)
        assert data['version'] == '2.3.0'

    def test_diagnostics(self, sample_returns):
        opt = MasterCanopy(method='HRP')
        opt.cluster(sample_returns).allocate()
        diag = opt.diagnostics()
        assert 'covariance' in diag
        assert 'marchenko_pastur' in diag
        assert 'portfolio' in diag
        assert diag['marchenko_pastur']['n_signal_eigenvalues'] >= 0
