"""Tests for hot-reload parameter system."""

from __future__ import annotations

import json
import os
import tempfile
import threading
import time

os.environ.setdefault("HORIZON_API_KEY", "test-key-123")

from horizon._horizon import Engine
from horizon.context import Context, FeedData, InventorySnapshot
from horizon.hot_reload import ParamReloader, hot_reload, write_params


def _make_ctx(params=None):
    """Build a minimal Context for testing pipeline functions."""
    return Context(
        feeds={},
        inventory=InventorySnapshot(),
        params=params if params is not None else {},
    )


# ---------------------------------------------------------------------------
# ParamReloader
# ---------------------------------------------------------------------------

class TestParamReloaderBasic:
    """Basic ParamReloader functionality."""

    def test_load_from_temp_json(self):
        """ParamReloader loads params from a JSON file on construction."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"spread": 0.05, "size": 10.0}, f)
            path = f.name
        try:
            reloader = ParamReloader(path, poll_interval=0.0)
            params = reloader.current()
            assert params == {"spread": 0.05, "size": 10.0}
        finally:
            os.unlink(path)

    def test_current_returns_copy(self):
        """current() returns a copy, not the internal dict."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"a": 1.0}, f)
            path = f.name
        try:
            reloader = ParamReloader(path, poll_interval=0.0)
            c1 = reloader.current()
            c1["b"] = 2.0
            c2 = reloader.current()
            assert "b" not in c2
        finally:
            os.unlink(path)


class TestParamReloaderChangeDetection:
    """File change detection via mtime."""

    def test_check_returns_none_if_unchanged(self):
        """check() returns None when the file hasn't changed."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"x": 1.0}, f)
            path = f.name
        try:
            reloader = ParamReloader(path, poll_interval=0.0)
            # Initial load already happened, so check should return None
            result = reloader.check()
            assert result is None
        finally:
            os.unlink(path)

    def test_check_detects_file_change(self):
        """check() returns new params when the file is rewritten."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"x": 1.0}, f)
            path = f.name
        try:
            reloader = ParamReloader(path, poll_interval=0.0)
            # Ensure mtime advances
            time.sleep(0.05)
            with open(path, "w") as f2:
                json.dump({"x": 2.0, "y": 3.0}, f2)
            result = reloader.check()
            assert result is not None
            assert result["x"] == 2.0
            assert result["y"] == 3.0
        finally:
            os.unlink(path)


class TestParamReloaderErrorHandling:
    """Graceful error handling for edge cases."""

    def test_missing_file(self):
        """ParamReloader handles missing file gracefully."""
        reloader = ParamReloader("/tmp/nonexistent_horizon_test.json", poll_interval=0.0)
        assert reloader.current() == {}
        assert reloader.check() is None

    def test_invalid_json(self):
        """ParamReloader handles malformed JSON gracefully."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            f.write("{not valid json!!")
            path = f.name
        try:
            reloader = ParamReloader(path, poll_interval=0.0)
            assert reloader.current() == {}
        finally:
            os.unlink(path)

    def test_non_object_json(self):
        """ParamReloader handles JSON arrays/strings gracefully."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump([1, 2, 3], f)
            path = f.name
        try:
            reloader = ParamReloader(path, poll_interval=0.0)
            assert reloader.current() == {}
        finally:
            os.unlink(path)

    def test_non_float_values_skipped(self):
        """Non-numeric values are silently skipped."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"good": 1.5, "bad": "hello", "also_bad": [1, 2], "int_ok": 42}, f)
            path = f.name
        try:
            reloader = ParamReloader(path, poll_interval=0.0)
            params = reloader.current()
            assert params == {"good": 1.5, "int_ok": 42.0}
            assert "bad" not in params
            assert "also_bad" not in params
        finally:
            os.unlink(path)


# ---------------------------------------------------------------------------
# hot_reload() pipeline function
# ---------------------------------------------------------------------------

class TestHotReloadPipelineDict:
    """hot_reload with a dict source."""

    def test_dict_source_injects_params(self):
        """Dict source injects values into ctx.params on each call."""
        source = {"spread": 0.05, "size": 10.0}
        fn = hot_reload(source=source)
        ctx = _make_ctx()
        fn(ctx)
        assert ctx.params["spread"] == 0.05
        assert ctx.params["size"] == 10.0

    def test_dict_source_detects_changes(self):
        """Dict source detects when values are mutated."""
        source = {"x": 1.0}
        changes = []
        fn = hot_reload(source=source, on_change=lambda old, new: changes.append((old, new)))
        ctx1 = _make_ctx()
        fn(ctx1)
        # First call always triggers on_change (old is empty)
        assert len(changes) == 1

        # No change -- on_change should NOT fire again
        ctx2 = _make_ctx()
        fn(ctx2)
        assert len(changes) == 1

        # Mutate source -- on_change fires
        source["x"] = 2.0
        ctx3 = _make_ctx()
        fn(ctx3)
        assert len(changes) == 2
        assert changes[1][1]["x"] == 2.0


class TestHotReloadPipelineFile:
    """hot_reload with a file path source."""

    def test_file_source_creates_reloader(self):
        """String source auto-creates a ParamReloader."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"alpha": 0.1}, f)
            path = f.name
        try:
            fn = hot_reload(source=path)
            ctx = _make_ctx()
            fn(ctx)
            assert ctx.params["alpha"] == 0.1
        finally:
            os.unlink(path)

    def test_file_source_detects_update(self):
        """File source picks up changes when file is rewritten."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"v": 1.0}, f)
            path = f.name
        try:
            # Use a ParamReloader with zero poll interval so the test
            # doesn't get throttled by the default 1-second interval.
            reloader = ParamReloader(path, poll_interval=0.0)
            fn = hot_reload(source=reloader, prefix="")
            ctx1 = _make_ctx()
            fn(ctx1)
            assert ctx1.params["v"] == 1.0

            # Ensure mtime advances (macOS HFS+ has 1-second granularity)
            time.sleep(1.1)
            write_params(path, {"v": 99.0})

            ctx2 = _make_ctx()
            fn(ctx2)
            assert ctx2.params["v"] == 99.0
        finally:
            os.unlink(path)


class TestHotReloadPrefix:
    """Prefix injection."""

    def test_prefix_prepended_to_keys(self):
        """Prefix is prepended to every injected key."""
        source = {"spread": 0.05, "size": 10.0}
        fn = hot_reload(source=source, prefix="mm.")
        ctx = _make_ctx()
        fn(ctx)
        assert "mm.spread" in ctx.params
        assert "mm.size" in ctx.params
        assert ctx.params["mm.spread"] == 0.05
        # Raw keys should NOT be present (only prefixed ones)
        assert "spread" not in ctx.params


class TestHotReloadOnChange:
    """on_change callback."""

    def test_on_change_receives_old_and_new(self):
        """on_change callback receives old and new param dicts."""
        source = {"a": 1.0}
        captured = []
        fn = hot_reload(source=source, on_change=lambda old, new: captured.append((dict(old), dict(new))))
        ctx1 = _make_ctx()
        fn(ctx1)
        # First invocation: old is empty, new is the initial params
        assert len(captured) == 1
        assert captured[0][0] == {}
        assert captured[0][1] == {"a": 1.0}

    def test_on_change_error_does_not_crash(self):
        """A failing on_change callback does not crash the pipeline."""
        source = {"a": 1.0}

        def bad_callback(old, new):
            raise RuntimeError("boom")

        fn = hot_reload(source=source, on_change=bad_callback)
        ctx = _make_ctx()
        # Should not raise
        fn(ctx)
        assert ctx.params["a"] == 1.0


# ---------------------------------------------------------------------------
# write_params
# ---------------------------------------------------------------------------

class TestWriteParams:
    """write_params helper."""

    def test_write_and_read_roundtrip(self):
        """write_params writes valid JSON that ParamReloader can read."""
        with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
            path = f.name
        try:
            write_params(path, {"spread": 0.05, "size": 10.0})
            reloader = ParamReloader(path, poll_interval=0.0)
            assert reloader.current() == {"spread": 0.05, "size": 10.0}
        finally:
            os.unlink(path)

    def test_write_overwrites_existing(self):
        """write_params overwrites the file with new content."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"old": 1.0}, f)
            path = f.name
        try:
            write_params(path, {"new": 2.0})
            with open(path) as fh:
                data = json.load(fh)
            assert data == {"new": 2.0}
            assert "old" not in data
        finally:
            os.unlink(path)


# ---------------------------------------------------------------------------
# Engine integration
# ---------------------------------------------------------------------------

class TestEngineIntegration:
    """Engine runtime param round-trip (update_param / get_param)."""

    def test_update_and_get_param(self):
        """update_param + get_param round-trips correctly."""
        engine = Engine()
        engine.update_param("spread", 0.05)
        assert engine.get_param("spread") == 0.05

    def test_get_all_params(self):
        """get_all_params returns all set parameters."""
        engine = Engine()
        engine.update_param("a", 1.0)
        engine.update_param("b", 2.0)
        params = engine.get_all_params()
        assert params == {"a": 1.0, "b": 2.0}

    def test_update_params_batch(self):
        """update_params_batch sets multiple params at once."""
        engine = Engine()
        engine.update_params_batch({"x": 10.0, "y": 20.0})
        assert engine.get_param("x") == 10.0
        assert engine.get_param("y") == 20.0

    def test_remove_param(self):
        """remove_param removes a parameter."""
        engine = Engine()
        engine.update_param("gone", 999.0)
        assert engine.get_param("gone") == 999.0
        removed = engine.remove_param("gone")
        assert removed is True
        assert engine.get_param("gone") is None

    def test_remove_nonexistent_param(self):
        """remove_param returns False for a key that doesn't exist."""
        engine = Engine()
        removed = engine.remove_param("nope")
        assert removed is False

    def test_get_param_missing(self):
        """get_param returns None for unknown keys."""
        engine = Engine()
        assert engine.get_param("missing") is None

    def test_hot_reload_pushes_to_engine(self):
        """hot_reload pipeline function pushes params to engine via update_params_batch."""
        engine = Engine()
        source = {"spread": 0.05}
        fn = hot_reload(source=source)
        ctx = _make_ctx(params={"engine": engine})
        fn(ctx)
        assert engine.get_param("spread") == 0.05


# ---------------------------------------------------------------------------
# Thread safety
# ---------------------------------------------------------------------------

class TestThreadSafety:
    """Concurrent reads and writes do not crash."""

    def test_concurrent_reloader_access(self):
        """Multiple threads can read/write the param file concurrently."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump({"v": 0.0}, f)
            path = f.name

        reloader = ParamReloader(path, poll_interval=0.0)
        errors = []
        stop = threading.Event()

        def writer():
            for i in range(50):
                if stop.is_set():
                    break
                try:
                    write_params(path, {"v": float(i)})
                except Exception as exc:
                    errors.append(exc)
                time.sleep(0.001)

        def reader():
            for _ in range(100):
                if stop.is_set():
                    break
                try:
                    reloader.check()
                    reloader.current()
                except Exception as exc:
                    errors.append(exc)
                time.sleep(0.001)

        threads = [
            threading.Thread(target=writer),
            threading.Thread(target=reader),
            threading.Thread(target=reader),
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10.0)

        stop.set()
        os.unlink(path)

        assert errors == [], f"Thread safety errors: {errors}"

    def test_concurrent_dict_source(self):
        """Mutating a dict source from another thread doesn't crash."""
        source = {"x": 1.0}
        fn = hot_reload(source=source)
        errors = []
        stop = threading.Event()

        def mutator():
            for i in range(100):
                if stop.is_set():
                    break
                source["x"] = float(i)
                time.sleep(0.001)

        def runner():
            for _ in range(100):
                if stop.is_set():
                    break
                try:
                    ctx = _make_ctx()
                    fn(ctx)
                except Exception as exc:
                    errors.append(exc)
                time.sleep(0.001)

        threads = [
            threading.Thread(target=mutator),
            threading.Thread(target=runner),
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10.0)

        stop.set()
        assert errors == [], f"Thread safety errors: {errors}"
