"""Heartbeat tasks: parse per-interval checks from HEARTBEAT.md and pick due ones.

HEARTBEAT.md supports an optional ``tasks:`` block (YAML-ish) at the top that
declares per-task intervals and prompts. Example:

    ## Tasks (auto-parsed)
    ```yaml
    tasks:
      - name: inbox-triage
        interval: 30m
        prompt: "Flag urgent unread email."
    ```

This module is the single parser/selector for that block:

- ``parse_heartbeat_tasks(workspace)`` reads HEARTBEAT.md and extracts tasks.
- ``due_tasks(tasks, now=None)`` diffs against per-task last-run state.
- ``record_task_run(name)`` persists the last-run timestamp.

Per-task state lives at ``~/.mugi/heartbeat_tasks_state.json`` (``0o600``,
atomic via ``secure_atomic_write``). Intervals are clamped to ``[5m, 7d]`` to
prevent runaway scheduling from an accidentally short or absurdly long entry.

The module is intentionally permissive: missing block, malformed block, or
partially invalid entries never raise — they are treated as "no tasks" so the
runner falls back to the legacy checklist-only behavior.
"""

from __future__ import annotations

import json
import logging
import os
import re
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

from persistence import MUGI_DIR, secure_atomic_write
from utils.path_safety import safe_resolve_path

_log = logging.getLogger(__name__)

HEARTBEAT_TASKS_STATE_PATH = os.path.join(MUGI_DIR, "heartbeat_tasks_state.json")

MIN_INTERVAL_SECONDS = 5 * 60
MAX_INTERVAL_SECONDS = 7 * 24 * 60 * 60
DEFAULT_INTERVAL_SECONDS = 30 * 60

MAX_TASKS = 16
MAX_NAME_LEN = 64
MAX_PROMPT_LEN = 1_000

_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\- ]{0,63}$")


@dataclass(frozen=True)
class HeartbeatTask:
    name: str
    interval_seconds: int
    prompt: str


def _parse_interval(value: Any) -> int | None:
    """Parse an interval like '30m', '2h', '1d', '90s', or a bare number (seconds)."""
    if isinstance(value, bool):
        return None
    if isinstance(value, (int, float)):
        seconds = int(value)
        return seconds if seconds > 0 else None
    if not isinstance(value, str):
        return None
    raw = value.strip().lower()
    if not raw:
        return None
    m = re.fullmatch(r"\s*(\d+)\s*([smhd]?)\s*", raw)
    if not m:
        return None
    n = int(m.group(1))
    unit = m.group(2) or "s"
    mult = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
    return n * mult


def _clamp_interval(seconds: int) -> int:
    return max(MIN_INTERVAL_SECONDS, min(MAX_INTERVAL_SECONDS, int(seconds)))


def _strip_quotes(s: str) -> str:
    s = s.strip()
    if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
        return s[1:-1]
    return s


_YAML_BLOCK_RE = re.compile(
    r"```(?:ya?ml)?\s*\n(?P<body>tasks\s*:[\s\S]*?)\n```",
    re.IGNORECASE,
)


def _extract_tasks_block(heartbeat_text: str) -> str | None:
    """Return the raw YAML tasks block body, or None if missing."""
    if not heartbeat_text or "tasks:" not in heartbeat_text:
        return None
    m = _YAML_BLOCK_RE.search(heartbeat_text)
    if m:
        return m.group("body")
    # Fallback: unfenced tasks: block — tolerate it. Start from the first
    # 'tasks:' line and read until the next blank line followed by a heading
    # or a fenced block, or end of file.
    idx = heartbeat_text.find("\ntasks:")
    if idx < 0 and heartbeat_text.startswith("tasks:"):
        idx = 0
    else:
        idx = idx + 1 if idx >= 0 else -1
    if idx < 0:
        return None
    tail = heartbeat_text[idx:]
    end = len(tail)
    # Stop at a markdown heading after a blank line or end of string.
    stop_re = re.compile(r"\n\s*\n(?=#|```)")
    ms = stop_re.search(tail)
    if ms:
        end = ms.start()
    return tail[:end]


def _parse_tasks_yaml(body: str) -> list[dict[str, Any]]:
    """Parse the tasks list from the YAML body using a small hand-rolled parser.

    Avoids a PyYAML dependency. Supports the structure:

        tasks:
          - name: foo
            interval: 30m
            prompt: "bar"
    """
    if not body:
        return []
    lines = body.splitlines()
    entries: list[dict[str, Any]] = []
    current: dict[str, Any] | None = None
    started = False
    kv_re = re.compile(r"^(?P<indent>\s*)(?:-\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*:\s*(?P<val>.*)$")
    for raw_line in lines:
        line = raw_line.rstrip()
        if not line.strip() or line.strip().startswith("#"):
            continue
        stripped = line.lstrip()
        if stripped.startswith("tasks:"):
            started = True
            continue
        if not started:
            continue
        m = kv_re.match(line)
        if not m:
            continue
        indent_len = len(m.group("indent"))
        key = m.group("key").strip()
        val = m.group("val").strip()
        is_list_item = stripped.startswith("- ")
        if is_list_item:
            if current:
                entries.append(current)
                if len(entries) >= MAX_TASKS:
                    current = None
                    break
            current = {}
            if key and val != "":
                current[key] = _strip_quotes(val)
            elif key:
                current[key] = ""
            continue
        if current is None:
            continue
        if indent_len < 2:
            continue
        current[key] = _strip_quotes(val)
    if current and len(entries) < MAX_TASKS:
        entries.append(current)
    return entries


def _sanitize_tasks(entries: list[dict[str, Any]]) -> list[HeartbeatTask]:
    out: list[HeartbeatTask] = []
    seen: set[str] = set()
    for entry in entries:
        if not isinstance(entry, dict):
            continue
        name = str(entry.get("name") or "").strip()[:MAX_NAME_LEN]
        prompt = str(entry.get("prompt") or "").strip()[:MAX_PROMPT_LEN]
        raw_interval = entry.get("interval")
        if not name or not prompt:
            continue
        if not _NAME_RE.match(name):
            continue
        interval = _parse_interval(raw_interval)
        if interval is None or interval <= 0:
            interval = DEFAULT_INTERVAL_SECONDS
        interval = _clamp_interval(interval)
        key = name.lower()
        if key in seen:
            continue
        seen.add(key)
        out.append(HeartbeatTask(name=name, interval_seconds=interval, prompt=prompt))
        if len(out) >= MAX_TASKS:
            break
    return out


def _resolve_heartbeat_path(workspace: str) -> str | None:
    for candidate in ("HEARTBEAT.md", "HEARTBEAT.MD"):
        resolved, err = safe_resolve_path(workspace, candidate, allow_write=False)
        if err or not resolved:
            continue
        if os.path.isfile(resolved):
            return resolved
    return None


def parse_heartbeat_tasks(workspace: str) -> list[HeartbeatTask]:
    """Read ``<workspace>/HEARTBEAT.md`` and return the parsed, validated tasks.

    Returns an empty list when:
    - workspace is empty or unreadable,
    - HEARTBEAT.md is missing,
    - HEARTBEAT.md has no ``tasks:`` block,
    - or the block is malformed beyond repair.
    """
    if not workspace or not isinstance(workspace, str):
        return []
    path = _resolve_heartbeat_path(workspace)
    if not path:
        return []
    try:
        with open(path, "r", encoding="utf-8") as f:
            text = f.read(256_000)
    except OSError as e:
        _log.debug("heartbeat_tasks: could not read %s: %s", path, e)
        return []
    body = _extract_tasks_block(text)
    if not body:
        return []
    try:
        raw_entries = _parse_tasks_yaml(body)
    except Exception:
        _log.debug("heartbeat_tasks: YAML parse failed", exc_info=True)
        return []
    return _sanitize_tasks(raw_entries)


def _load_state() -> dict[str, Any]:
    if not os.path.isfile(HEARTBEAT_TASKS_STATE_PATH):
        return {}
    try:
        with open(HEARTBEAT_TASKS_STATE_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        if isinstance(data, dict):
            return data
    except Exception as e:
        _log.debug("heartbeat_tasks state load: %s", e)
    return {}


def _save_state(state: dict[str, Any]) -> None:
    try:
        secure_atomic_write(
            HEARTBEAT_TASKS_STATE_PATH,
            json.dumps(state, indent=2, ensure_ascii=True, sort_keys=True),
        )
    except Exception as e:
        _log.debug("heartbeat_tasks state save: %s", e)


def _parse_iso(ts: str) -> float | None:
    if not ts:
        return None
    try:
        s = ts.strip().replace("Z", "+00:00")
        dt = datetime.fromisoformat(s)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt.timestamp()
    except Exception:
        return None


def due_tasks(
    tasks: list[HeartbeatTask],
    *,
    now: float | None = None,
    state: dict[str, Any] | None = None,
) -> list[HeartbeatTask]:
    """Return the subset of ``tasks`` whose interval has elapsed since last run."""
    if not tasks:
        return []
    current = float(now) if now is not None else time.time()
    st = state if state is not None else _load_state()
    out: list[HeartbeatTask] = []
    for task in tasks:
        entry = st.get(task.name)
        last_ts: float | None = None
        if isinstance(entry, dict):
            last_ts = _parse_iso(str(entry.get("last_run") or ""))
        if last_ts is None:
            out.append(task)
            continue
        if current - last_ts >= task.interval_seconds:
            out.append(task)
    return out


def record_task_run(task_name: str, *, when: float | None = None) -> None:
    """Persist a last-run timestamp for ``task_name``."""
    name = str(task_name or "").strip()
    if not name:
        return
    ts = float(when) if when is not None else time.time()
    iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
    state = _load_state()
    state[name] = {"last_run": iso}
    _save_state(state)


def record_many(task_names: list[str], *, when: float | None = None) -> None:
    """Persist last-run timestamps for a batch of tasks in one write."""
    names = [str(n or "").strip() for n in (task_names or []) if str(n or "").strip()]
    if not names:
        return
    ts = float(when) if when is not None else time.time()
    iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
    state = _load_state()
    for name in names:
        state[name] = {"last_run": iso}
    _save_state(state)


def compose_tasks_prompt(
    tasks: list[HeartbeatTask],
    *,
    now_str: str | None = None,
) -> str:
    """Compose a heartbeat prompt body that lists one section per due task."""
    if not tasks:
        return ""
    now = now_str or datetime.now().strftime("%Y-%m-%d %H:%M")
    lines = [
        "You are in background heartbeat mode. Current time: " + now,
        "",
        "Each section below is a separate periodic check that is now due.",
        "Address each one in turn. If a check has nothing to report, say so briefly.",
        "",
    ]
    for task in tasks:
        lines.append(f"### Task: {task.name}")
        lines.append(task.prompt.strip())
        lines.append("")
    lines.extend([
        "After all sections, if NOTHING across all tasks needs attention, reply exactly \"HEARTBEAT_OK\".",
        "If an action is urgent according to HEARTBEAT.md notification rules, prefix your reply with 'URGENT:'.",
        "Never open UI, never send chat messages unless explicitly told 'notify_user'.",
    ])
    return "\n".join(lines).strip() + "\n"
