#!/usr/bin/env python3
"""Genius presence heartbeat (amarre v2, Phase 2) — CONSERVATIVE.

Emits one presence ping (~every PING_INTERVAL) to the Genius capture/presence endpoint
ONLY when there is REAL recent activity, so it binds worked-time to the consultant's
machine clock without counting an idle-open VSCode:

  ACTIVE  := a workspace SOURCE file was edited in the last IDLE_WINDOW   (human work)
          OR the Claude transcript / agent files grew in the last IDLE_WINDOW
             (a /loop or Workflow is executing — counts even with no human interaction)
  IDLE    := neither -> NO ping (VSCode open but nothing running is NOT counted)

Metadata only: workspace path + git branch (for project classification + retroactive
attribution) + machine-clock timestamp + tz. No transcript content. Never blocks, always
exit 0. Single-instance per machine (lockfile). Self-terminates when the Claude session
is gone (transcript dir idle > STOP_IDLE) or after MAX_LIFETIME.
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.request

HOME = os.path.expanduser("~")
CFG = os.path.join(HOME, ".genius", "config.json")
LOCK = os.path.join(HOME, ".genius", "presence.lock")
STOP_FLAG = os.path.join(HOME, ".genius", "presence.stop")
PROJECTS = os.path.join(HOME, ".claude", "projects")

PING_INTERVAL = 300        # 5 min between ticks
IDLE_WINDOW = 600          # a file touched in the last 10 min => active this tick
STOP_IDLE = 2700           # Claude dir quiet 45 min => session over, exit
MAX_LIFETIME = 18 * 3600   # hard stop
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".next", "dist", "build",
             ".venv", "venv", ".mypy_cache", ".pytest_cache", "outbox"}


def _cfg():
    try:
        with open(CFG) as fh:
            return json.load(fh)
    except Exception:
        return {}


def _pid_alive(pid):
    """Cross-platform 'is this pid running?'. Returns True (alive), False (provably
    gone) or None (INDETERMINATE — couldn't tell). CRITICAL: on Windows os.kill(pid, 0)
    maps to TerminateProcess and would KILL the process instead of probing it, so the
    lock must NOT use os.kill there — use tasklist. If tasklist is absent or times out
    we return None (indeterminate), NOT False, so an unreadable probe never lets a new
    daemon SEIZE a live peer's lock (that would double-count presence). The MAX_LIFETIME
    mtime-staleness gate in main() still reclaims a genuinely crashed daemon's lock."""
    if not pid:
        return False
    try:
        if os.name == "nt":
            out = subprocess.run(
                ["tasklist", "/FI", "PID eq %d" % pid, "/NH"],
                capture_output=True, text=True, timeout=5)
            if out.returncode != 0:
                return None  # tasklist errored -> indeterminate
            return ("%d" % pid) in (out.stdout or "")
        os.kill(pid, 0)
        return True
    except OSError:
        return False        # POSIX: no such process
    except Exception:
        return None         # tasklist missing / timeout -> indeterminate


def _recent(path, window_s, budget_s=2.0):
    """True if any non-skipped file under path has mtime within window_s. Bounded by a
    small wall-clock budget so a huge tree never stalls a tick. Early-returns."""
    cutoff = time.time() - window_s
    deadline = time.time() + budget_s
    try:
        for root, dirs, files in os.walk(path):
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
            for f in files:
                try:
                    if os.path.getmtime(os.path.join(root, f)) >= cutoff:
                        return True
                except OSError:
                    pass
            if time.time() > deadline:
                break
    except OSError:
        pass
    return False


def _project_dir(cwd):
    """Best-effort: the ~/.claude/projects/<slug> dir for this cwd, else the most
    recently-modified project dir (the active session)."""
    slug = re.sub(r"[^A-Za-z0-9]+", "-", cwd)
    cand = os.path.join(PROJECTS, slug)
    if os.path.isdir(cand):
        return cand
    # fallback: most-recently-touched project dir
    try:
        best, best_m = None, 0
        for d in os.listdir(PROJECTS):
            p = os.path.join(PROJECTS, d)
            if os.path.isdir(p):
                m = os.path.getmtime(p)
                if m > best_m:
                    best, best_m = p, m
        return best
    except OSError:
        return None


def _git_branch(cwd):
    try:
        out = subprocess.run(["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
                             capture_output=True, text=True, timeout=3)
        b = (out.stdout or "").strip()
        return b if b and b != "HEAD" else ""
    except Exception:
        return ""


def _post(cfg, payload):
    base = (cfg.get("base_url") or "").rstrip("/")
    token = cfg.get("token") or ""
    if not base or not token:
        return False
    url = base + "/xma/genius/v1/capture/presence"
    body = json.dumps({"jsonrpc": "2.0", "method": "call",
                       "params": payload, "id": 1}).encode()
    req = urllib.request.Request(url, data=body, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + token)
    req.add_header("User-Agent", "GeniusPresence/1.0")  # WAF blocks default Python-urllib UA
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            json.loads(resp.read().decode("utf-8", "replace") or "{}")
        return True
    except Exception:
        return False


def _work_mode(cwd, claude_dir):
    """Label HOW the work is happening (owner: loop/workflow vs short interactions).
    A workspace edit = the human is present -> interactive. Otherwise, if a Workflow/
    subagent run is active -> autonomous (Claude working alone, e.g. overnight). Default
    interactive (a normal Claude turn)."""
    if _recent(cwd, IDLE_WINDOW):
        return "interactive"
    if claude_dir:
        for sub in ("subagents", os.path.join("subagents", "workflows")):
            if _recent(os.path.join(claude_dir, sub), IDLE_WINDOW, budget_s=1.5):
                return "autonomous"
    return "interactive"


def _ping(cfg, cwd, session_ref, claude_dir):
    payload = {
        "session_ref": session_ref,
        "occurred_at_epoch": time.time(),
        "utc_offset_minutes": -int(time.timezone // 60) if not time.daylight
                              else -int(time.altzone // 60),
        "workspace": cwd[:256],
        "git_branch": _git_branch(cwd)[:128],
        "surface": "vscode",
        "work_mode": _work_mode(cwd, claude_dir),
    }
    return _post(cfg, payload)


def _is_active(cwd, claude_dir):
    """Conservative: real work signal in the last IDLE_WINDOW — human edit OR Claude
    execution (loop/workflow). Idle-open VSCode touches no file -> not active."""
    if _recent(cwd, IDLE_WINDOW):
        return True
    if claude_dir and _recent(claude_dir, IDLE_WINDOW):
        return True
    return False


def _claude_idle_too_long(claude_dir):
    return not (claude_dir and _recent(claude_dir, STOP_IDLE, budget_s=2.0))


def main():
    args = sys.argv[1:]
    once = "--once" in args
    cwd = os.getcwd()
    session_ref = "vscode:%s" % time.strftime("%Y-%m-%d")
    for i, a in enumerate(args):
        if a == "--cwd" and i + 1 < len(args):
            cwd = args[i + 1]
        if a == "--session" and i + 1 < len(args):
            session_ref = args[i + 1]
    cfg = _cfg()
    claude_dir = _project_dir(cwd)

    if once:
        active = _is_active(cwd, claude_dir)
        ok = _ping(cfg, cwd, session_ref, claude_dir) if active else False
        print(json.dumps({"active": active, "posted": ok, "cwd": cwd,
                          "claude_dir": claude_dir, "session_ref": session_ref}))
        return

    # single-instance lock (cross-platform: _pid_alive is Windows-safe — a bare
    # os.kill(pid, 0) here would TERMINATE the other daemon on Windows). Back off
    # (don't start) when the lock owner is alive OR indeterminate, UNLESS the lock is
    # older than MAX_LIFETIME — then it is a stale/crashed daemon (or a reused PID) and
    # we reclaim it. This avoids both a duplicate daemon (double-counted presence) and
    # a permanently-dead amarre from a stale lock.
    try:
        if os.path.exists(LOCK):
            with open(LOCK) as fh:
                pid = int((fh.read() or "0").strip() or 0)
            try:
                lock_age = time.time() - os.path.getmtime(LOCK)
            except OSError:
                lock_age = 0
            if pid and _pid_alive(pid) is not False and lock_age <= MAX_LIFETIME:
                return            # a live (or unverifiable, fresh) daemon owns it
            # else: provably gone / indeterminate+stale -> take it over
        with open(LOCK, "w") as fh:
            fh.write(str(os.getpid()))
    except Exception:
        pass

    start = time.time()
    try:
        while True:
            if os.path.exists(STOP_FLAG):
                break
            if time.time() - start > MAX_LIFETIME:
                break
            claude_dir = _project_dir(cwd)  # refresh (slug may resolve later)
            # Heartbeat the lock so a live daemon keeps its mtime fresh — the takeover
            # gate uses (now - lock mtime) > MAX_LIFETIME to reclaim a crashed daemon's
            # lock, so a running one must keep it recent.
            try:
                os.utime(LOCK, None)
            except OSError:
                pass
            if _is_active(cwd, claude_dir):
                _ping(cfg, cwd, session_ref, claude_dir)
            elif _claude_idle_too_long(claude_dir):
                break  # session truly over
            time.sleep(PING_INTERVAL)
    finally:
        try:
            if os.path.exists(LOCK):
                os.remove(LOCK)
        except OSError:
            pass


if __name__ == "__main__":
    try:
        main()
    except Exception:
        pass
    sys.exit(0)
