"""
Tests for tools/generate_pine_mlp_presets.py (Pine MLP codegen).

Tests:
  1. Float round-trip: 7-sig-fig formatting (fmt_float9) round-trips weights within 1e-6 relative error.
  2. Sentinel replacement: rewriting the sentinel block in a minimal Pine file is idempotent.
  3. Weight init coverage: every generated block contains _w1/_b1/_w2/_b2/_w3/_b3 assignments.
"""
import json
import os
from pathlib import Path
import re
import sys
import tempfile

import numpy as np
import pytest

REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, REPO)

from strategies.strategy_mlp_scores import save_mlp_artifact, FEATURE_COLS
from tools.generate_pine_mlp_presets import (
    fmt_float9,
    emit_weight_init,
    generate_block,
    SENTINEL_START,
    SENTINEL_END,
    _ASSETS,
    _TFS,
    _THRESHOLD_PARAMS,
)
from tools.check_pine_plot_budget import PLOT_LIMIT, count_plot_calls


def test_pine_exports_only_normalized_trailing_stop_diagnostics():
    pine_path = Path(REPO) / "strategies" / "strategy_mlp_scores.pine"
    pine = pine_path.read_text()
    exported_titles = set(re.findall(r'\bplot(?:char)?\s*\([^\n]*?title\s*=\s*"([^"]+)"', pine))

    assert 'title="trail_stop_price_ratio"' in pine
    assert 'title="trail_high_price_ratio"' in pine
    assert set(FEATURE_COLS) <= exported_titles
    assert re.search(r"\bplotchar\s*\(\s*close\s*[,)]", pine) is None
    assert re.search(r"\bplotchar\s*\(\s*strategy\.position_size\s*[,)]", pine) is None
    assert count_plot_calls(str(pine_path)) <= PLOT_LIMIT


# ── Float round-trip ─────────────────────────────────────────────────────────

class TestFloatFormatting:
    """fmt_float9 should round-trip float64 values with ≤ 1e-6 relative error (7 sig figs)."""

    def test_round_trip_random_weights(self):
        rng = np.random.default_rng(42)
        vals = rng.uniform(-5.0, 5.0, 500)
        for v in vals:
            s = fmt_float9(v)
            rt = float(s)
            if abs(v) > 1e-15:
                rel_err = abs(rt - v) / abs(v)
                assert rel_err < 1e-6, f"Round-trip failed: {v!r} → '{s}' → {rt!r}, rel_err={rel_err}"

    def test_zero(self):
        assert fmt_float9(0.0) == "0.0"

    def test_very_small(self):
        v = 1.23456789e-7
        s = fmt_float9(v)
        assert abs(float(s) - v) / abs(v) < 1e-6

    def test_very_large(self):
        v = 1.23456789e7
        s = fmt_float9(v)
        assert abs(float(s) - v) / abs(v) < 1e-6

    def test_has_decimal_point_or_exponent(self):
        """All formatted floats must be valid Pine float literals."""
        rng = np.random.default_rng(7)
        vals = rng.uniform(-100.0, 100.0, 200)
        for v in vals:
            s = fmt_float9(v)
            assert "." in s or "e" in s, f"No decimal/exponent in: '{s}'"


# ── emit_weight_init ──────────────────────────────────────────────────────────

class TestEmitWeightInit:
    def _make_layers(self, arch, seed=0):
        rng = np.random.default_rng(seed)
        layers = []
        for i in range(len(arch) - 1):
            W = rng.uniform(-1, 1, (arch[i+1], arch[i]))
            b = rng.uniform(-0.1, 0.1, arch[i+1])
            layers.append((W, b))
        return layers

    def test_produces_pine_lines(self):
        arch = [5, 3, 2, 1]
        layers = self._make_layers(arch)
        lines = emit_weight_init(layers, arch)
        pine_text = "\n".join(lines)
        assert "_w1" in pine_text and "_b1" in pine_text
        assert "_w2" in pine_text and "_b2" in pine_text
        assert "_w3" in pine_text and "_b3" in pine_text

    def test_round_trip_weights(self):
        """Floats emitted by emit_weight_init parse back to within 1e-9 of originals."""
        arch = [49, 16, 8, 1]
        layers = self._make_layers(arch, seed=1)
        lines = emit_weight_init(layers, arch)
        pine_text = "\n".join(lines)

        # Extract all float literals from array.from(...) calls
        all_literals = re.findall(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", pine_text)
        floats_in_code = [float(x) for x in all_literals if "." in x or "e" in x.lower()]

        # Flatten all original weights for comparison
        all_orig = np.concatenate([
            np.concatenate([W.ravel(), b.ravel()]) for W, b in layers
        ])

        assert len(floats_in_code) >= len(all_orig), (
            f"Expected ≥{len(all_orig)} floats, got {len(floats_in_code)}")

        # Check that every original weight value appears in the code within round-trip tolerance
        for w in all_orig:
            best = min(floats_in_code, key=lambda x: abs(x - w))
            if abs(w) > 1e-15:
                assert abs(best - w) / abs(w) < 1e-6, (
                    f"Weight {w} not found in emitted code (closest: {best})")

    def test_no_array_from_over_1000_args(self):
        """Each array.from() call should fit within reasonable size for Pine."""
        arch = [49, 16, 8, 1]
        rng = np.random.default_rng(2)
        layers = [(rng.uniform(-1, 1, (arch[i+1], arch[i])),
                   np.zeros(arch[i+1])) for i in range(len(arch) - 1)]
        lines = emit_weight_init(layers, arch)
        for line in lines:
            if "array.from(" in line:
                n_args = line.count(",") + 1
                assert n_args <= 200, f"array.from() has {n_args} args (>200): {line[:80]}"


# ── Sentinel replacement ──────────────────────────────────────────────────────

MINIMAL_PINE_TEMPLATE = """//@version=6
strategy("test")
{start_marker} — placeholder
// some content inside sentinel
{end_marker} — placeholder end

// code after sentinel
float x = 1.0
"""


class TestSentinelReplacement:
    def _make_pine_file(self, tmp_path):
        content = MINIMAL_PINE_TEMPLATE.format(
            start_marker=SENTINEL_START,
            end_marker=SENTINEL_END,
        )
        pine_path = tmp_path / "test_mlp.pine"
        pine_path.write_text(content)
        return str(pine_path)

    def _make_preset(self, tmp_path, arch=(49, 4, 2, 1)):
        """Create a minimal artifact for one BTC TF."""
        rng = np.random.default_rng(99)
        layers = [(rng.uniform(-1, 1, (arch[i+1], arch[i])),
                   np.zeros(arch[i+1])) for i in range(len(arch) - 1)]
        path = str(tmp_path / "mlp_weights_COINBASE_BTCUSD_6H.json")
        save_mlp_artifact(path, layers, asset="COINBASE_BTCUSD", timeframe="6H",
                          training={"recommended_thresholds": {
                              "i_long_entry_activation_threshold": 50.0,
                              "i_long_exit_activation_threshold": 80.0,
                              "i_long_exit_activation_confirmation_threshold": 20.0,
                              "i_trailing_stop_threshold": 0.0,
                              "i_use_long_exit_confirmation": 1.0,
                              "i_use_long_entry_confirmation": False,
                          }})
        return path

    def test_sentinel_markers_present_after_rewrite(self, tmp_path):
        from tools.generate_pine_mlp_presets import (
            load_presets, generate_block, rewrite_pine,
            WEIGHTS_DIR as WDIR
        )
        import tools.generate_pine_mlp_presets as codegen

        art_path = self._make_preset(tmp_path)
        pine_path = self._make_pine_file(tmp_path)

        # Monkey-patch paths to tmp
        orig_weights_dir = codegen.WEIGHTS_DIR
        orig_pine_file   = codegen.PINE_FILE
        orig_winners_dir = codegen.WINNERS_DIR
        codegen.WEIGHTS_DIR  = str(tmp_path)
        codegen.PINE_FILE    = pine_path
        codegen.WINNERS_DIR  = str(tmp_path)  # no winner CSVs
        try:
            presets = codegen.load_presets()
            # Only 6H will be present (single artifact)
            assert len(presets) >= 1
            block   = codegen.generate_block(presets)
            codegen.rewrite_pine(block, dry_run=False)
        finally:
            codegen.WEIGHTS_DIR  = orig_weights_dir
            codegen.PINE_FILE    = orig_pine_file
            codegen.WINNERS_DIR  = orig_winners_dir

        result = open(pine_path).read()
        assert SENTINEL_START in result
        assert SENTINEL_END   in result
        assert "// code after sentinel" in result, "Code after sentinel was lost"

    def test_rewrite_is_idempotent(self, tmp_path):
        from tools.generate_pine_mlp_presets import (
            load_presets, generate_block, rewrite_pine
        )
        import tools.generate_pine_mlp_presets as codegen

        self._make_preset(tmp_path)
        pine_path = self._make_pine_file(tmp_path)

        orig_weights_dir = codegen.WEIGHTS_DIR
        orig_pine_file   = codegen.PINE_FILE
        orig_winners_dir = codegen.WINNERS_DIR
        codegen.WEIGHTS_DIR  = str(tmp_path)
        codegen.PINE_FILE    = pine_path
        codegen.WINNERS_DIR  = str(tmp_path)
        try:
            presets = codegen.load_presets()
            block   = codegen.generate_block(presets)
            # First rewrite
            codegen.rewrite_pine(block, dry_run=False)
            content1 = open(pine_path).read()
            # Second rewrite (block unchanged — timestamps differ but structure doesn't)
            block2 = codegen.generate_block(presets)
            codegen.rewrite_pine(block2, dry_run=False)
            content2 = open(pine_path).read()
        finally:
            codegen.WEIGHTS_DIR  = orig_weights_dir
            codegen.PINE_FILE    = orig_pine_file
            codegen.WINNERS_DIR  = orig_winners_dir

        # Sentinel presence and post-sentinel code should be identical
        assert SENTINEL_START in content1 and SENTINEL_START in content2
        assert SENTINEL_END   in content1 and SENTINEL_END   in content2
        # Weight literals should be identical (deterministic from same artifact)
        w_lines1 = [l for l in content1.splitlines() if "array.from" in l]
        w_lines2 = [l for l in content2.splitlines() if "array.from" in l]
        assert w_lines1 == w_lines2, "Weight literals changed between rewrites"

    def test_weight_arrays_in_block(self, tmp_path):
        import tools.generate_pine_mlp_presets as codegen
        self._make_preset(tmp_path)
        orig_weights_dir = codegen.WEIGHTS_DIR
        orig_winners_dir = codegen.WINNERS_DIR
        codegen.WEIGHTS_DIR  = str(tmp_path)
        codegen.WINNERS_DIR  = str(tmp_path)
        try:
            presets = codegen.load_presets()
            block   = codegen.generate_block(presets)
        finally:
            codegen.WEIGHTS_DIR  = orig_weights_dir
            codegen.WINNERS_DIR  = orig_winners_dir

        assert "_w1 :=" in block or "array.concat" in block
        assert "_b1 :=" in block
        assert "_w2 :=" in block
        assert "_b2 :=" in block
        assert "_w3 :=" in block
        assert "_b3 :=" in block
