import json
from collections import Counter
from dataclasses import replace

import numpy as np
import pytest

from strategies.mlp_comparison import ComparisonSnapshot, require_replay
import tools.run_mlp_deep_sweep as sweep


@pytest.fixture(autouse=True)
def comparison_inputs(tmp_path, monkeypatch):
    """Small deterministic market/model fixtures; no private chart exports."""
    import pandas as pd
    times = pd.date_range('2017-11-01', '2026-03-05', freq='6h')
    phase = np.arange(len(times)) * .11
    frame = pd.DataFrame(dict(time=times.as_unit('s').astype('int64'),
        close=100. + 5. * np.sin(phase), high=101. + 5. * np.sin(phase),
        stoch_norm=np.sin(phase), stoch_peak_norm=np.cos(phase), mvrv_regime=0.))
    data = tmp_path / 'bars.csv'
    frame.to_csv(data, index=False)
    artifact = tmp_path / 'model.json'
    artifact.write_text(json.dumps(dict(feature_cols=['stoch_norm'],
        layers=[dict(W=[[1.]], b=[0.])], arch=[1, 1], training={})))
    winner = tmp_path / 'winner.csv'
    pd.DataFrame([sweep.recommended_from_artifact(str(artifact))]).to_csv(winner, index=False)
    monkeypatch.setattr(sweep, 'data_path', lambda tf: data)
    monkeypatch.setattr(sweep, 'winner_path', lambda tf: winner)
    monkeypatch.setattr(sweep, 'artifact_path', lambda tf: str(artifact))
    monkeypatch.setattr(sweep, 'ASSET', 'COINBASE_BTCUSD')
    monkeypatch.setattr(sweep, '_COMPARISON', None)
    return data


def valid(**overrides):
    row = dict(Comparison_Fingerprint='same', Evaluation_Status='complete', source='candidate',
               **{'P&L/DD Ratio': 10., 'Calmar Ratio': 2., 'Total P&L %': 100.,
                  'Fragile_0.05': 0, 'Sub1_Calmar': 1., 'Sub2_Calmar': 1.,
                  'WFO_Neg_Folds': 0, 'OOS_Total Trades': 2, 'OOS_Sortino Ratio': 3.,
                  'OOS_Bear_Pct': 0.})
    row.update(overrides)
    return row


def test_incompatible_high_history_is_provenance_only():
    candidate = valid()
    incumbent = valid(**{'Calmar Ratio': 1., 'P&L/DD Ratio': 9.})
    historical = valid(**{'Calmar Ratio': 1000., 'P&L/DD Ratio': 1000., 'OOS_Sortino Ratio': 999.})
    assert sweep.promotable(candidate, incumbent, historical)
    assert not sweep.promotable(valid(**{'OOS_Sortino Ratio': 1.}), incumbent, historical)
    assert not sweep.promotable(valid(WFO_Neg_Folds=1), incumbent, historical)
    assert not sweep.promotable(valid(**{'Sub1_Calmar': 0.}), incumbent, historical)
    assert not sweep.promotable(valid(Comparison_Fingerprint='other'), incumbent)


@pytest.mark.parametrize('status', ['failed', 'incomplete'])
def test_failed_or_incomplete_incumbent_fails_closed(status):
    incumbent = valid(Evaluation_Status=status)
    assert not sweep.promotable(valid(), incumbent)
    with pytest.raises(ValueError):
        require_replay(incumbent)


def test_snapshot_and_compatible_cache(monkeypatch):
    snapshot = sweep.capture_comparison('6H', [sweep.load_winner('6H')['mlp_weights_file']])
    params = sweep.normalise_param(sweep.load_winner('6H'))
    monkeypatch.setattr(sweep, '_COMPARISON', snapshot)
    cache = {}
    first = sweep.evaluate('6H', params, cache)
    assert len(cache) == 1
    assert sweep.evaluate('6H', params, cache) == first
    assert len(cache) == 1
    changed = replace(snapshot, manifest_json=snapshot.manifest_json + ' ')
    monkeypatch.setattr(sweep, '_COMPARISON', changed)
    assert sweep.evaluate('6H', params, cache)['Comparison_Fingerprint'] != first['Comparison_Fingerprint']
    assert len(cache) == 2
    periods = json.loads(first['Period_Measurements'])
    assert len(periods) == len(sweep.WFO_FOLDS) + 4
    assert all('entries' in period or period.get('coverage') == 'empty' for period in periods)


def test_nan_model_is_failed_replay(monkeypatch):
    path = sweep.load_winner('6H')['mlp_weights_file']
    snapshot = sweep.capture_comparison('6H', [path])
    payload = json.loads(snapshot.artifacts[0][1])
    payload['layers'][0]['W'][0][0] = np.nan
    snapshot = replace(snapshot, artifacts=((path, json.dumps(payload).encode()),))
    monkeypatch.setattr(sweep, '_COMPARISON', snapshot)
    with pytest.raises(ValueError):
        require_replay(sweep.evaluate('6H', sweep.normalise_param(sweep.load_winner('6H')), {}))


def test_equal_additional_threshold_budget(tmp_path):
    artifact = tmp_path / "comparison-model.json"
    from pathlib import Path
    artifact.write_bytes(Path(sweep.load_winner("6H")["mlp_weights_file"]).read_bytes())
    samples = 23
    candidates = sweep.generate_candidates('6H', samples, 77, extra_weights=[
        str(artifact)])
    per_model = {}
    for row in candidates:
        if row['source'] == 'equal_tuning':
            per_model.setdefault(row['mlp_weights_file'], []).append({k:v for k,v in row.items() if k != 'mlp_weights_file'})
    assert len(per_model) >= 2
    values = list(per_model.values())
    assert all(rows == values[0] for rows in values)
    pairs = {(row["i_use_long_entry_confirmation"], row["i_use_long_exit_confirmation"]) for row in values[0]}
    assert pairs == {(False, False), (False, True), (True, False), (True, True)}
    pair_counts = Counter((r['i_use_long_entry_confirmation'], r['i_use_long_exit_confirmation']) for r in values[0])
    assert max(pair_counts.values()) - min(pair_counts.values()) <= 1
    assert len(values[0]) == samples // len(per_model)
    assert len(candidates) == len(per_model) * (1 + samples // len(per_model))


def test_data_and_source_hashes_change_context(tmp_path):
    repo = sweep.REPO
    original = sweep.capture_comparison('6H', [sweep.load_winner('6H')['mlp_weights_file']])
    source = tmp_path / 'bars.csv'
    source.write_bytes(original.data + b'\n')
    contract = json.loads(original.manifest_json)['contract']
    artifact = original.artifacts[0][0]
    changed_data = ComparisonSnapshot.capture(source, [artifact], repo=repo,
                    asset=sweep.ASSET, tf='6H', contract=contract)
    assert changed_data.fingerprint != original.fingerprint
    manifest = json.loads(original.manifest_json)
    for path in manifest['source_hashes']:
        target = tmp_path / path
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes((repo / path).read_bytes())
    (tmp_path / 'strategies/mlp_accounting.py').write_text('# changed accounting')
    source.write_bytes(original.data)
    changed_accounting = ComparisonSnapshot.capture(source, [artifact], repo=tmp_path,
                    asset=sweep.ASSET, tf='6H', contract=contract)
    assert changed_accounting.fingerprint != original.fingerprint


def test_truncated_data_is_incomplete(monkeypatch):
    import io
    import pandas as pd
    winner = sweep.load_winner('6H')
    snapshot = sweep.capture_comparison('6H', [winner['mlp_weights_file']])
    frame = pd.read_csv(io.BytesIO(snapshot.data)).iloc[:100]
    snapshot = replace(snapshot, data=frame.to_csv(index=False).encode())
    monkeypatch.setattr(sweep, '_COMPARISON', snapshot)
    row = sweep.evaluate('6H', sweep.normalise_param(winner), {})
    assert row['Evaluation_Status'] == 'incomplete'
    with pytest.raises(ValueError):
        require_replay(row)


def test_force_does_not_bypass_failed_incumbent(monkeypatch):
    original = sweep.evaluate
    def failed(tf, params, cache):
        row = original(tf, params, cache)
        if params.get('source') == 'current':
            row['Evaluation_Status'] = 'failed'
        return row
    monkeypatch.setattr(sweep, 'evaluate', failed)
    monkeypatch.setattr(sweep, 'write_winner', lambda *args: pytest.fail('must not write'))
    with pytest.raises(ValueError, match='Incumbent replay'):
        sweep.run_tf('6H', 0, 1, 10, False, force_promote=True)


def test_run_tf_rejects_nonreproducing_incumbent(monkeypatch):
    original = sweep.evaluate
    count = 0
    def unstable(tf, params, cache):
        nonlocal count
        row = original(tf, params, cache)
        if params.get('source') == 'current':
            count += 1
            row['Calmar Ratio'] += count
        return row
    monkeypatch.setattr(sweep, 'evaluate', unstable)
    monkeypatch.setattr(sweep, 'write_winner', lambda *args: pytest.fail('must not write'))
    with pytest.raises(ValueError, match='not reproducible'):
        sweep.run_tf('6H', 0, 1, 10, False)


def test_nonfinite_incumbent_parameter_is_rejected():
    winner = sweep.load_winner('6H')
    winner['i_long_entry_activation_threshold'] = 'nan'
    with pytest.raises(ValueError, match='Nonfinite parameter'):
        sweep.normalise_param(winner)


def test_existing_empty_winner_is_not_a_new_asset(tmp_path, monkeypatch):
    path = tmp_path / 'winner.csv'
    path.write_text('mlp_weights_file,Calmar Ratio\n')
    monkeypatch.setattr(sweep, 'winner_path', lambda tf: path)
    with pytest.raises(ValueError, match='no replayable row'):
        sweep.load_winner('6H')


@pytest.mark.parametrize('asset,start,status', [
    ('COINBASE_BTCUSD', '2017-11-01', 'complete'),
    ('COINBASE_ETHUSD', '2017-12-02', 'incomplete'),
    ('COINBASE_BTCUSD', '2020-01-01', 'incomplete'),
    ('BINANCE_SOLUSD', '2020-04-01', 'complete'),
    ('BINANCE_LINKUSD', '2019-01-01', 'complete'),
    ('BINANCE_SOLUSD', '2020-10-01', 'incomplete'),
    ('BINANCE_LINKUSD', '2021-01-01', 'incomplete'),
])
def test_explicit_asset_history_bounds(comparison_inputs, monkeypatch, asset, start, status):
    import pandas as pd
    frame = pd.read_csv(comparison_inputs)
    frame = frame.loc[frame.time >= pd.Timestamp(start).timestamp()]
    frame.to_csv(comparison_inputs, index=False)
    monkeypatch.setattr(sweep, 'ASSET', asset)
    params = sweep.normalise_param(sweep.load_winner('6H'))
    snapshot = sweep.capture_comparison('6H', [params['mlp_weights_file']])
    monkeypatch.setattr(sweep, '_COMPARISON', snapshot)
    row = sweep.evaluate('6H', params, {})
    assert row['Evaluation_Status'] == status
    manifest = json.loads(snapshot.manifest_json)
    assert manifest['observed_coverage']['first'] == str(pd.Timestamp(start))
    assert manifest['observed_coverage']['bars'] == len(frame) - 1
    expected_kind = 'bounded-available-history' if asset.startswith('BINANCE') else 'full-score-window'
    assert manifest['contract']['history_coverage']['kind'] == expected_kind


@pytest.mark.parametrize('end', ['2026-02-27', '2026-03-01 06:00'])
def test_short_history_still_requires_training_end_and_oos(comparison_inputs, monkeypatch, end):
    import pandas as pd
    frame = pd.read_csv(comparison_inputs)
    frame = frame.loc[(frame.time >= pd.Timestamp('2020-04-01').timestamp()) &
                      (frame.time <= pd.Timestamp(end).timestamp())]
    frame.to_csv(comparison_inputs, index=False)
    monkeypatch.setattr(sweep, 'ASSET', 'BINANCE_SOLUSD')
    params = sweep.normalise_param(sweep.load_winner('6H'))
    monkeypatch.setattr(sweep, '_COMPARISON', sweep.capture_comparison('6H', [params['mlp_weights_file']]))
    assert sweep.evaluate('6H', params, {})['Evaluation_Status'] == 'incomplete'


def test_each_window_accounted_once(monkeypatch):
    import strategies.mlp_accounting as accounting
    real = accounting.window_metrics
    calls = []
    def measured(signals, start=None, end=None):
        calls.append((start, signals.time.max()))
        return real(signals, start=start, end=end)
    monkeypatch.setattr(accounting, 'window_metrics', measured)
    params = sweep.normalise_param(sweep.load_winner('6H'))
    monkeypatch.setattr(sweep, '_COMPARISON', sweep.capture_comparison('6H', [params['mlp_weights_file']]))
    row = sweep.evaluate('6H', params, {})
    assert len(calls) == len(sweep.WFO_FOLDS) + 4
    assert all('pnl_pct' in p for p in json.loads(row['Period_Measurements']))
