"""Freeze BTC6H research candidates and append observations without retuning."""
from __future__ import annotations

import argparse
import json
from pathlib import Path
import subprocess
import sys

import numpy as np
import pandas as pd

REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO))
from config import SCORE_START, TRAIN_START
from strategies.mlp_accounting import net_returns, window_metrics
from strategies.mlp_comparison import digest
from strategies.mlp_execution_stability import assess_stability, stability_periods
from strategies.mlp_score_structure import confirmed_score_structure
from strategies.strategy_mlp_scores import _score_to_signals, generate_signals
from tools.run_mlp_entry_loss_experiment import evaluator, FROZEN, MODEL_HASH, hashes, protected_hashes

REFERENCE = REPO / 'results/mlp_threshold_comparison_2026_09_21/final'
SOURCES = ['config.py', 'strategies/mlp_accounting.py', 'strategies/mlp_comparison.py',
    'strategies/mlp_execution_stability.py', 'strategies/strategy_mlp_scores.py',
    'strategies/strategy_activation_scores.py', 'strategies/library_activation_scores.py',
    'strategies/mlp_temporal_features.py', 'strategies/mlp_score_structure.py',
    'tools/run_mlp_deep_sweep.py', 'tools/run_mlp_entry_loss_experiment.py',
    'tools/mlp_observation.py', 'docs/mlp_validation_upgrade_2026_09_24.md']
IDS = ('incumbent', 'hybrid9090', 'higher_low_3')


def now_utc() -> pd.Timestamp:
    return pd.Timestamp.now(tz='UTC')


def save(path: Path, value: object) -> None:
    path.write_text(json.dumps(value, sort_keys=True, indent=2, allow_nan=False) + '\n')


def read_chart(raw: bytes, asof: pd.Timestamp) -> pd.DataFrame:
    import io
    frame = pd.read_csv(io.BytesIO(raw))
    frame.columns = frame.columns.str.lower().str.strip()
    unit = 's' if pd.api.types.is_numeric_dtype(frame.time) else None
    frame['time'] = pd.to_datetime(frame.time, unit=unit, utc=True).dt.tz_localize(None)
    if len(frame) < 2 or frame.time.isna().any() or frame.time.duplicated().any() or not frame.time.is_monotonic_increasing:
        raise ValueError('Invalid chart timestamps/coverage')
    frame = frame.iloc[:-1].copy().reset_index(drop=True)
    if (frame.time + pd.Timedelta(hours=6) > asof.tz_convert(None)).any():
        raise ValueError('Retained chart contains unclosed or future bars')
    return frame


def row_hashes(frame: pd.DataFrame, columns: list[str]) -> list[list[str]]:
    result = []
    for row in frame[['time'] + columns].itertuples(index=False, name=None):
        time = pd.Timestamp(row[0]).isoformat()
        values = [float(v).hex() for v in row[1:]]
        result.append([time, digest(json.dumps(values, separators=(',', ':')).encode())])
    return result


def require_history(expected: list, actual: list) -> None:
    lookup = dict(actual)
    if any(lookup.get(time) != value for time, value in expected):
        raise ValueError('Captured history changed or is missing; preserve this export for a revision review')


def coverage_status(days: float, completed: int) -> str:
    if days <= 0:
        return 'awaiting_data'
    return 'ready_for_review' if days >= 180 and completed >= 10 else 'insufficient_coverage'


def policy_signal(width: int):
    def signal(scored, params, stoch, close, high):
        gate = None if width == 0 else confirmed_score_structure(
            scored.activation_score.to_numpy(), left=width, right=width).bullish_state
        return _score_to_signals(scored, params, stoch, close, high, entry_gate=gate)
    return signal


def validate_features(frame: pd.DataFrame, required: list[str]) -> None:
    missing = sorted(set(required) - set(frame.columns))
    if missing:
        raise ValueError(f'Missing observation features: {missing}')
    measured = frame.loc[frame.time >= pd.Timestamp(SCORE_START)]
    if measured.empty or not np.isfinite(measured[required].to_numpy(dtype=float)).all():
        raise ValueError('Missing or nonfinite observation values')
    if (measured[['close', 'high']] <= 0).any().any():
        raise ValueError('Nonpositive observation prices')


def check_bundle(bundle: Path) -> dict:
    seal = json.loads((bundle / 'seal.json').read_text())
    for path, expected in seal.items():
        if digest((bundle / path).read_bytes()) != expected:
            raise ValueError(f'Frozen bundle changed: {path}')
    manifest = json.loads((bundle / 'manifest.json').read_text())
    if [p['id'] for p in manifest['policies']] != list(IDS):
        raise ValueError('Unexpected frozen policies')
    for path, expected in manifest['source_hashes'].items():
        if digest((REPO / path).read_bytes()) != expected:
            raise ValueError(f'Frozen evaluator source drift: {path}')
    return manifest


def freeze(bundle: Path, data: Path) -> None:
    if bundle.exists():
        raise ValueError('Freeze directory already exists; never overwrite a freeze')
    protected = protected_hashes()
    logs = {}
    for script in ('tools/sync_params.py', 'tools/check_pine.py'):
        run = subprocess.run([sys.executable, script], cwd=REPO, text=True, capture_output=True)
        logs[script] = dict(returncode=run.returncode, output=run.stdout + run.stderr)
        if run.returncode:
            raise ValueError(f'Preflight failed: {script}')
    module = evaluator()
    incumbent = module.normalise_param(module.load_winner('6H'))
    if any(incumbent[k] != v for k, v in FROZEN.items()) or digest(Path(incumbent['mlp_weights_file']).read_bytes()) != MODEL_HASH:
        raise ValueError('Original incumbent changed')
    summary = json.loads((REFERENCE / 'summary.json').read_text())
    hybrid_row = next(m['best_ranked'] for m in summary['models'] if m['model'] == 'hybrid_grouped_v1_seed9090')
    hybrid = module.normalise_param(hybrid_row)
    if digest(Path(hybrid['mlp_weights_file']).read_bytes()) != hybrid_row['Artifact_SHA256']:
        raise ValueError('Original hybrid artifact changed')
    if hybrid['i_long_exit_activation_confirmation_threshold'] != -730:
        raise ValueError('Original hybrid threshold changed')
    asof = now_utc()
    raw = data.read_bytes()
    frame = read_chart(raw, asof)
    required = list(dict.fromkeys(json.loads(Path(incumbent['mlp_weights_file']).read_bytes())['feature_cols'] + ['close', 'high', 'stoch_peak_norm']))
    if hybrid.get('i_mvrv_suppress_bear') or incumbent.get('i_mvrv_suppress_bear'):
        required.append('mvrv_regime')
    validate_features(frame, required)
    bundle.mkdir(parents=True)
    (bundle / 'models').mkdir()
    policies = []
    historical = []
    for name, params, width in [('incumbent', incumbent, 0), ('hybrid9090', hybrid, 0), ('higher_low_3', incumbent, 3)]:
        path = f'models/{name}.json'
        model_bytes = Path(params['mlp_weights_file']).read_bytes()
        (bundle / path).write_bytes(model_bytes)
        frozen_params = {k: v for k, v in params.items() if k in module.PARAM_COLS and k != 'mlp_weights_file'}
        policy = dict(id=name, params=frozen_params, model=path, model_sha256=digest(model_bytes), entry_pivot_width=width)
        policies.append(policy)
        replay_params = dict(frozen_params, mlp_weights_file=str(bundle/path), _pine_time_start=SCORE_START)
        scored = generate_signals(frame.loc[frame.time >= pd.Timestamp(TRAIN_START)].copy(), **replay_params)
        signal = policy_signal(width)
        report = assess_stability(scored, replay_params, scored.stoch_peak_norm.to_numpy(),
            scored.close.to_numpy(), scored.high.to_numpy(), signal, stability_periods())
        actual = signal(scored.copy(), replay_params, scored.stoch_peak_norm.to_numpy(), scored.close.to_numpy(), scored.high.to_numpy())
        historical.append(dict(id=name, historical_only=True, stability=report,
            periods=[dict(name=n, **window_metrics(actual, start=s, end=e)) for n,s,e in stability_periods()]))
    # Freeze occurs after historical validation. It cannot admit those observations.
    frozen_at = now_utc()
    manifest = dict(version=1, frozen_at=frozen_at.isoformat(),
        observation_start=(frozen_at.floor('6h') + pd.Timedelta(hours=6)).isoformat(), asset='COINBASE_BTCUSD', timeframe='6H',
        minimum_completed_trades=10, minimum_days=180, required_columns=required,
        anchor_data_sha256=digest(raw), last_anchor_bar=frame.time.iloc[-1].isoformat(),
        source_hashes=hashes([REPO/p for p in SOURCES]), policies=policies,
        promotions_allowed=False, parity_status='not_established')
    save(bundle/'manifest.json', manifest)
    save(bundle/'anchor_rows.json', row_hashes(frame, required))
    save(bundle/'historical_validation.json', historical)
    save(bundle/'preflights.json', logs)
    save(bundle/'protected_hashes.json', protected)
    for relative in SOURCES:
        target = bundle / 'source_snapshot' / relative
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes((REPO/relative).read_bytes())
    if protected != protected_hashes() or data.read_bytes() != raw:
        raise ValueError('Protected/input files changed during freeze')
    save(bundle/'seal.json', {str(p.relative_to(bundle)):digest(p.read_bytes()) for p in sorted(bundle.rglob('*')) if p.is_file()})
    check_bundle(bundle)
    print(json.dumps(dict(bundle=str(bundle), frozen_at=manifest['frozen_at'], status='frozen', policies=list(IDS))))


def observe(bundle: Path, data: Path) -> dict:
    manifest = check_bundle(bundle)
    asof = now_utc()
    raw = data.read_bytes()
    frame = read_chart(raw, asof)
    validate_features(frame, manifest['required_columns'])
    actual_hashes = row_hashes(frame, manifest['required_columns'])
    require_history(json.loads((bundle/'anchor_rows.json').read_text()), actual_hashes)
    observations = bundle / 'observations'
    previous = sorted(observations.glob('*/receipt.json')) if observations.exists() else []
    last_digest = None
    for prior in previous:
        prior_row = json.loads(prior.read_text())
        if prior_row['bundle_seal_sha256'] != digest((bundle/'seal.json').read_bytes()):
            raise ValueError('Observation belongs to a different frozen bundle')
        if prior_row['previous_receipt_sha256'] != last_digest:
            raise ValueError('Observation chain changed')
        for path, expected in prior_row['files'].items():
            if digest((prior.parent/path).read_bytes()) != expected:
                raise ValueError('Previous observation changed')
        last_digest = digest(prior.read_bytes())
    if previous:
        require_history(json.loads((previous[-1].parent/'rows.json').read_text()), actual_hashes)
        if json.loads(previous[-1].read_text())['data_sha256'] == digest(raw):
            return json.loads((previous[-1].parent/'report.json').read_text())
    start = pd.Timestamp(manifest['observation_start']).tz_convert(None)
    forward = frame.loc[frame.time >= start]
    if not forward.empty and (forward.time.iloc[0] != start or (forward.time.diff().dropna() != pd.Timedelta(hours=6)).any()):
        raise ValueError('Missing forward bars; cannot evaluate a continuous frozen policy')
    reports, traces = [], {}
    days = 0. if forward.empty else (forward.time.iloc[-1] + pd.Timedelta(hours=6) - start).total_seconds()/86400
    for policy in manifest['policies']:
        if forward.empty:
            reports.append(dict(id=policy['id'], status='awaiting_data', metrics=None, completed_trades=0))
            continue
        params = dict(policy['params'], mlp_weights_file=str(bundle/policy['model']), _pine_time_start=str(start))
        scored = generate_signals(frame.loc[frame.time >= pd.Timestamp(TRAIN_START)].copy(), **params)
        trace = policy_signal(policy['entry_pivot_width'])(scored.copy(), params, scored.stoch_peak_norm.to_numpy(), scored.close.to_numpy(), scored.high.to_numpy())
        trace['net_return'] = net_returns(trace)
        trace = trace.loc[trace.time >= start].copy()
        metrics = window_metrics(trace, start=str(start))
        completed = int(trace.execute_exit.sum())
        reports.append(dict(id=policy['id'], status=coverage_status(days, completed), metrics=metrics, completed_trades=completed))
        traces[policy['id']] = trace
    report = dict(observed_at=asof.isoformat(), frozen_at=manifest['frozen_at'], observation_start=manifest['observation_start'],
        last_closed_bar=frame.time.iloc[-1].isoformat(), calendar_days=days, policies=reports,
        status='awaiting_data' if forward.empty else 'observing', promotions_allowed=False,
        parity_status='not_established', historical_gap_count=int((frame.time.diff().dropna() > pd.Timedelta(hours=6)).sum()))
    output = observations / f'{len(previous)+1:06d}'
    output.mkdir(parents=True, exist_ok=False)
    save(output/'report.json', report)
    save(output/'rows.json', actual_hashes)
    for name, trace in traces.items():
        trace.to_csv(output/f'{name}_bars.csv', index=False)
    check_bundle(bundle)
    save(output/'receipt.json', dict(data_sha256=digest(raw), bundle_seal_sha256=digest((bundle/'seal.json').read_bytes()),
        previous_receipt_sha256=last_digest, files={p.name:digest(p.read_bytes()) for p in output.iterdir() if p.is_file()}))
    return report


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('command', choices=['freeze', 'observe'])
    parser.add_argument('--bundle', type=Path, required=True)
    parser.add_argument('--data', type=Path, default=REPO/'data/mlp/COINBASE_BTCUSD, 360.csv')
    args = parser.parse_args()
    if args.command == 'freeze':
        freeze(args.bundle.resolve(), args.data.resolve())
    else:
        print(json.dumps(observe(args.bundle.resolve(), args.data.resolve()), indent=2))


if __name__ == '__main__':
    main()
