"""Hot-reload system for runtime parameter updates.

Watches JSON files or mutable dicts for parameter changes and injects
them into the pipeline context and engine runtime params without
restarting the strategy.
"""

from __future__ import annotations

import json
import logging
import os
import threading
import time
from typing import Any, Callable

logger = logging.getLogger("horizon.hot_reload")


class ParamReloader:
    """Watches a JSON file for parameter changes using file mtime polling.

    Thread-safe: ``check()`` and ``current()`` can be called from any thread.

    The JSON file must contain a flat object with string keys and numeric
    values, e.g.::

        {"spread": 0.05, "size": 10.0, "threshold": 0.3}

    Non-float values are silently skipped with a warning.
    """

    def __init__(self, path: str, poll_interval: float = 1.0) -> None:
        self._path = os.path.abspath(path)
        self._poll_interval = max(0.01, poll_interval)
        self._lock = threading.Lock()
        self._last_mtime: float = 0.0
        self._last_check: float = 0.0
        self._params: dict[str, float] = {}
        self._loaded = False

        # Attempt initial load
        self._try_load()

    def check(self) -> dict[str, float] | None:
        """Check for parameter changes.

        Returns the new parameters dict if the file changed since the last
        check, or ``None`` if nothing changed.  Respects ``poll_interval``
        to avoid excessive stat calls.
        """
        now = time.monotonic()
        if now - self._last_check < self._poll_interval:
            return None
        self._last_check = now

        try:
            mtime = os.path.getmtime(self._path)
        except OSError:
            # File doesn't exist (yet) or is inaccessible
            return None

        if mtime <= self._last_mtime:
            return None

        return self._try_load()

    def current(self) -> dict[str, float]:
        """Return the most recently loaded parameters (never None)."""
        with self._lock:
            return dict(self._params)

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    def _try_load(self) -> dict[str, float] | None:
        """Read and parse the JSON file.  Returns params on success, None on failure."""
        try:
            mtime = os.path.getmtime(self._path)
        except OSError:
            if not self._loaded:
                logger.debug("Param file not found: %s", self._path)
            return None

        try:
            with open(self._path, "r") as fh:
                raw = json.load(fh)
        except json.JSONDecodeError as exc:
            logger.warning("Invalid JSON in %s: %s", self._path, exc)
            return None
        except OSError as exc:
            logger.warning("Cannot read %s: %s", self._path, exc)
            return None

        if not isinstance(raw, dict):
            logger.warning("Param file %s must contain a JSON object, got %s", self._path, type(raw).__name__)
            return None

        params: dict[str, float] = {}
        for key, value in raw.items():
            if not isinstance(key, str):
                continue
            if isinstance(value, (int, float)):
                params[str(key)] = float(value)
            else:
                logger.debug("Skipping non-numeric param %r = %r in %s", key, value, self._path)

        with self._lock:
            self._params = params
            self._last_mtime = mtime
            self._loaded = True

        logger.info("Loaded %d params from %s", len(params), self._path)
        return dict(params)


def hot_reload(
    source: str | ParamReloader | dict = "params.json",
    prefix: str = "",
    on_change: Callable[[dict[str, float], dict[str, float]], Any] | None = None,
) -> Callable:
    """Pipeline function that injects hot-reloaded parameters into the context.

    Drop this into your ``hz.run()`` pipeline to enable live parameter
    tuning without restarting::

        hz.run(
            ...,
            pipeline=[hot_reload("my_params.json"), my_strategy],
        )

    Args:
        source: One of:
            - A file path (str) -- creates a ``ParamReloader`` automatically.
            - A ``ParamReloader`` instance.
            - A plain ``dict`` -- wraps a mutable dict for programmatic updates.
        prefix: Optional prefix prepended to every key when injecting into
                ``ctx.params``.  For example ``prefix="mm."`` turns ``"spread"``
                into ``"mm.spread"``.
        on_change: Optional callback ``(old_params, new_params) -> Any`` invoked
                   whenever new parameters are detected.

    Returns:
        A single-argument pipeline function ``(ctx: Context) -> None``.
    """
    # Resolve source into a callable that returns (changed_or_none, current)
    if isinstance(source, str):
        reloader = ParamReloader(source)
    elif isinstance(source, ParamReloader):
        reloader = source
    elif isinstance(source, dict):
        reloader = None
    else:
        raise TypeError(f"source must be str, ParamReloader, or dict, got {type(source).__name__}")

    # Mutable state captured by the closure
    _prev_snapshot: dict[str, float] = {}

    def _pipeline_fn(ctx: Any) -> None:
        nonlocal _prev_snapshot

        try:
            if reloader is not None:
                changed = reloader.check()
                current = reloader.current() if changed is None else changed
            else:
                # Dict source: detect changes by comparing to previous snapshot
                assert isinstance(source, dict)
                current = {k: float(v) for k, v in source.items() if isinstance(v, (int, float))}
                changed = current if current != _prev_snapshot else None

            if changed is not None:
                old = dict(_prev_snapshot)
                _prev_snapshot = dict(current)

                # Fire callback
                if on_change is not None:
                    try:
                        on_change(old, changed)
                    except Exception as exc:
                        logger.warning("on_change callback error: %s", exc)

                # Push to engine if available
                engine = ctx.params.get("engine")
                if engine is not None:
                    try:
                        engine.update_params_batch(changed)
                    except Exception as exc:
                        logger.debug("Could not push params to engine: %s", exc)

            # Inject into ctx.params (always, even if unchanged -- ensures
            # params are present on every cycle)
            for key, value in current.items():
                ctx.params[prefix + key] = value

        except Exception as exc:
            logger.warning("hot_reload error: %s", exc)

    _pipeline_fn.__name__ = "hot_reload"
    _pipeline_fn.__qualname__ = "hot_reload.<pipeline>"
    return _pipeline_fn


def write_params(path: str, params: dict[str, float]) -> None:
    """Write parameters to a JSON file atomically.

    Uses a temporary file + rename to avoid partial reads by a concurrent
    ``ParamReloader``.

    Args:
        path: File path to write to.
        params: Flat dict of string keys to float values.
    """
    abs_path = os.path.abspath(path)
    tmp_path = abs_path + ".tmp"
    try:
        with open(tmp_path, "w") as fh:
            json.dump(params, fh, indent=2)
            fh.write("\n")
        os.replace(tmp_path, abs_path)
    except OSError as exc:
        logger.warning("Failed to write params to %s: %s", abs_path, exc)
        # Clean up temp file on failure
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
