"""Idle in-chat tick: claim, rate-limit, and eligibility helpers.

Persisted state lives under ``~/.mugi/`` (``secure_atomic_write``, ``0o600``).
All datetime handling uses UTC ISO strings; ``max_per_day`` boundary is user-local
calendar day by default (documented in config).

Claim file: ``~/.mugi/idle_tick_claim.json``
Rate state: ``~/.mugi/idle_tick_state.json``
"""

from __future__ import annotations

import json
import logging
import os
import time
from datetime import datetime, timezone
from typing import Any

from persistence import MUGI_DIR, secure_atomic_write

_log = logging.getLogger(__name__)

IDLE_TICK_CLAIM_PATH = os.path.join(MUGI_DIR, "idle_tick_claim.json")
IDLE_TICK_STATE_PATH = os.path.join(MUGI_DIR, "idle_tick_state.json")

# Defaults (overridable via TOML)
DEFAULT_IDLE_MINUTES = 5.0
DEFAULT_MAX_PER_DAY = 24
DEFAULT_COOLDOWN_SECONDS = 300  # per-thread cooldown
CLAIM_TTL_SECONDS = 120  # stale claim recovery

# Trusted idle tick template text — never loaded from workspace.
IDLE_TICK_TEMPLATE = (
    "Continue where we left off. Review recent context and take any appropriate next step — "
    "check on pending items, follow up on earlier threads, or surface anything worth noting."
)

IDLE_SKIP_SENTINEL = "IDLE_SKIP"


def _cache_text(value: Any) -> str:
    """Return trimmed cache text; tolerate null/non-string JSON values."""
    if value is None:
        return ""
    return str(value).strip()


def build_idle_tick_message() -> str:
    """Build a relational idle tick message from heartbeat cache and shared interests."""
    parts: list[str] = []

    parts.append("You have a quiet moment with your human. Here is what you know:")

    cache = _read_heartbeat_cache()
    if cache:
        summary = _cache_text(cache.get("summary"))
        reflection = _cache_text(cache.get("reflection"))
        if summary:
            parts.append(f"\nRecent status: {summary}")
        if reflection:
            parts.append(f"\nYour recent reflection: {reflection}")

    shared_interests = _get_shared_interest_summary()
    if shared_interests:
        parts.append(f"\nShared interests: {shared_interests}")

    pending = _get_pending_items_summary()
    if pending:
        parts.append(f"\nPending items: {pending}")

    parts.append(
        "\nIf something here sparks a thought, share it naturally — a follow-up question, "
        "an observation, something you came across, something you have been thinking about. "
        f"If nothing feels right, reply {IDLE_SKIP_SENTINEL}. Silence is fine. "
        "Do not rehearse context or list pending items mechanically."
    )

    return "\n".join(parts)


def _read_heartbeat_cache() -> dict:
    """Read the heartbeat summary cache."""
    cache_path = os.path.join(MUGI_DIR, "heartbeat_cache.json")
    if not os.path.isfile(cache_path):
        return {}
    try:
        with open(cache_path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def _get_shared_interest_summary() -> str:
    """Return a short summary of shared interests from the interest model."""
    try:
        from curiosity_scoring import InterestModel
        from core import get_workspace_path
        model = InterestModel(workspace=get_workspace_path())
        shared = model.get_shared_interests(limit=3)
        if shared:
            return ", ".join(topic for topic, _ in shared)
    except Exception:
        pass
    return ""


def _get_pending_items_summary() -> str:
    """Return a short summary of pending proactive items."""
    try:
        from persistence import PROACTIVE_QUEUE_PATH
        from utils.proactive_queue import pending_entries_from_lines
        if not os.path.isfile(PROACTIVE_QUEUE_PATH):
            return ""
        with open(PROACTIVE_QUEUE_PATH, "r", encoding="utf-8") as f:
            lines = f.readlines()
        entries = pending_entries_from_lines(lines, max_entries=3)
        if entries:
            items = [str(e.get("content", ""))[:80] for e in entries]
            return "; ".join(items)
    except Exception:
        pass
    return ""


# ---------------------------------------------------------------------------
# Claim (single-flight)
# ---------------------------------------------------------------------------

def _read_json_safe(path: str) -> dict:
    if not os.path.isfile(path):
        return {}
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def _write_json_secure(path: str, data: dict) -> None:
    os.makedirs(os.path.dirname(path) or MUGI_DIR, exist_ok=True)
    real = os.path.realpath(path)
    expected_dir = os.path.realpath(MUGI_DIR)
    if not real.startswith(expected_dir + os.sep) and real != expected_dir:
        _log.warning("idle_tick: path containment violation: %s", real)
        return
    secure_atomic_write(path, json.dumps(data, indent=2, ensure_ascii=True))
    try:
        os.chmod(path, 0o600)
    except OSError:
        pass


def acquire_idle_claim(owner: str) -> bool:
    """Try to acquire the single-flight idle claim. Returns True if acquired."""
    claim = _read_json_safe(IDLE_TICK_CLAIM_PATH)
    now = time.time()
    existing_ts = claim.get("ts")
    if existing_ts and (now - float(existing_ts)) < CLAIM_TTL_SECONDS:
        if claim.get("owner") != owner:
            return False
    _write_json_secure(IDLE_TICK_CLAIM_PATH, {"owner": owner, "ts": now})
    return True


def release_idle_claim(owner: str) -> None:
    """Release the claim if owned by us."""
    claim = _read_json_safe(IDLE_TICK_CLAIM_PATH)
    if claim.get("owner") == owner:
        try:
            os.unlink(IDLE_TICK_CLAIM_PATH)
        except OSError:
            pass


# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------

def _local_day_key() -> str:
    """User-local calendar day string (e.g. '2026-04-01')."""
    return datetime.now().strftime("%Y-%m-%d")


def _load_rate_state() -> dict:
    return _read_json_safe(IDLE_TICK_STATE_PATH)


def _save_rate_state(state: dict) -> None:
    _write_json_secure(IDLE_TICK_STATE_PATH, state)


def check_rate_limit(
    thread_id: str,
    *,
    max_per_day: int = DEFAULT_MAX_PER_DAY,
    cooldown_seconds: float = DEFAULT_COOLDOWN_SECONDS,
) -> tuple[bool, str]:
    """Check if idle tick is allowed for this thread. Returns (allowed, reason)."""
    state = _load_rate_state()
    now = time.time()
    day = _local_day_key()

    if str(state.get("day") or "") != day:
        state["day"] = day
        state["count_today"] = 0
        state["thread_last_fired"] = {}

    if int(state.get("count_today", 0)) >= max(1, max_per_day):
        return False, "daily idle tick limit reached"

    last_fired = state.get("thread_last_fired", {})
    if isinstance(last_fired, dict):
        last_ts = float(last_fired.get(thread_id, 0) or 0)
        if (now - last_ts) < cooldown_seconds:
            return False, f"per-thread cooldown ({cooldown_seconds}s)"

    return True, "ok"


def record_idle_tick_fired(thread_id: str) -> None:
    """Record that an idle tick was fired for rate-limiting purposes."""
    state = _load_rate_state()
    day = _local_day_key()
    if str(state.get("day") or "") != day:
        state["day"] = day
        state["count_today"] = 0
        state["thread_last_fired"] = {}
    state["count_today"] = int(state.get("count_today", 0)) + 1
    if not isinstance(state.get("thread_last_fired"), dict):
        state["thread_last_fired"] = {}
    state["thread_last_fired"][thread_id] = time.time()
    _save_rate_state(state)


# ---------------------------------------------------------------------------
# Eligibility (Python-side checks; Swift handles UI-layer guards)
# ---------------------------------------------------------------------------

def is_idle_tick_enabled(app_prefs: dict | None = None) -> bool:
    """Check if idle tick is enabled in app_prefs (default: True when key absent).

    Also gated by ``activeHoursV1`` (see utils.active_hours): when quiet hours
    apply, idle_tick returns False so the foreground assistant stays silent.
    Webhook bypass does NOT affect idle_tick (it's foreground-only).
    """
    try:
        from utils.active_hours import is_active_now as _ah_active
        if not _ah_active():
            return False
    except Exception:
        pass
    if not isinstance(app_prefs, dict):
        return True
    return bool(app_prefs.get("idle_in_chat_tick_enabled", True))


def get_idle_tick_config() -> dict[str, Any]:
    """Load idle tick config from TOML [agent_plane] section with defaults."""
    try:
        from core.config import get_agent_plane_config
        cfg = get_agent_plane_config()
    except Exception:
        cfg = {}
    idle_min = float(cfg.get("idle_tick_minutes", DEFAULT_IDLE_MINUTES))
    hot_raw = cfg.get("idle_tick_hot_thread_minutes")
    if hot_raw is None:
        hot_thread_minutes = 2.0 * idle_min
    else:
        hot_thread_minutes = float(hot_raw)
    return {
        "idle_minutes": idle_min,
        "max_per_day": int(cfg.get("idle_tick_max_per_day", DEFAULT_MAX_PER_DAY)),
        "cooldown_seconds": float(cfg.get("idle_tick_cooldown_seconds", DEFAULT_COOLDOWN_SECONDS)),
        "enabled": bool(cfg.get("enabled", True)),
        "hot_thread_minutes": hot_thread_minutes,
    }


def thread_is_too_hot_for_idle_tick(thread_id: str, *, hot_thread_minutes: float) -> tuple[bool, str]:
    """Return (too_hot, reason). When *too_hot*, skip firing idle tick for this thread.

    *hot_thread_minutes* <= 0 disables the guard (backward compatible).
    """
    if hot_thread_minutes <= 0:
        return False, "hot_thread_guard_disabled"
    try:
        from chat_storage import get_last_real_user_message_age_seconds
    except Exception:
        return False, "hot_thread_check_unavailable"
    age = get_last_real_user_message_age_seconds(thread_id)
    if age is None:
        return False, "no_real_user_message"
    if age < hot_thread_minutes * 60.0:
        return True, "thread_hot_recent_user"
    return False, "thread_cool"


def pending_mesh_blocks_idle_tick() -> tuple[bool, str]:
    """Skip idle tick while a mesh task is still waiting on a peer (outbound wait_for_result)."""
    try:
        from mesh import get_mesh_manager

        mgr = get_mesh_manager()
        if mgr is not None and getattr(mgr, "has_pending_task_results", lambda: False)():
            return True, "pending_mesh_task_wait"
    except Exception:
        pass
    return False, "ok"
