#!/usr/bin/env python3
"""heartbeat.py — Layer 03. The agent's pulse.

Once a day, cron wakes it; it reads the open work, and for each item Fable DECIDES (whether and
how) while cheaper models EXECUTE. Exactly two calls per task hit Fable — the RUN/HOLD decision
and the plan — so the day costs a few dollars instead of a few hundred. That cost structure IS
the point; the code is secondary.

    cron:  0 6 * * *  cd /path/to/repo && python ops/heartbeat.py >> ops/heartbeat.log 2>&1
    dry:   DRY_RUN=1 python ops/heartbeat.py     # decides + plans, writes/commits nothing

Requires: pip install anthropic ; gh ; jq ; ANTHROPIC_API_KEY in the environment.
"""
import json
import os
import subprocess
from pathlib import Path

import anthropic

OPS = Path(__file__).parent
M = json.loads((OPS / "models.json").read_text())
LAW = (OPS / "CONSTITUTION.md").read_text()
DRY_RUN = os.environ.get("DRY_RUN") == "1"

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

import trust          # noqa: E402  (Layer 04)
from quarantine import quarantine  # noqa: E402  (Layer 07)


# ---- pricing, per million tokens (input, output). Keep in sync with models.json. ----
PRICE = {
    "claude-fable-5":   (10.0, 50.0),
    "claude-opus-4-8":  (5.0, 25.0),
    "claude-sonnet-5":  (3.0, 15.0),
    "claude-haiku-4-5": (1.0, 5.0),
}


def ask(tier, prompt, effort=2000, max_tokens=4000, cache_law=True):
    """One call on a named tier ('decide' | 'review' | 'execute'). Returns text; logs spend."""
    model = M[tier]
    system = [{"type": "text", "text": LAW}]
    if cache_law:
        system[0]["cache_control"] = {"type": "ephemeral"}  # Layer 06: the constitution is stable → cache it
    r = client.messages.create(
        model=model,
        max_tokens=max_tokens,
        thinking={"type": "enabled", "budget_tokens": effort},
        system=system,
        messages=[{"role": "user", "content": prompt}],
    )
    _log_spend(model, r.usage)
    return "".join(b.text for b in r.content if b.type == "text")


def _log_spend(model, usage):
    pin, pout = PRICE.get(model, (0.0, 0.0))
    usd = (usage.input_tokens / 1e6) * pin + (usage.output_tokens / 1e6) * pout
    row = {"ts": _utcstamp(), "model": model,
           "in": usage.input_tokens, "out": usage.output_tokens, "usd": round(usd, 4)}
    with (OPS / "spend.jsonl").open("a") as f:
        f.write(json.dumps(row) + "\n")


def _utcstamp():
    from datetime import datetime, timezone
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")


def open_work():
    out = subprocess.run(
        ["gh", "issue", "list", "--label", "agent", "--json", "number,title,body"],
        capture_output=True, text=True)
    return json.loads(out.stdout or "[]")


def task_type_of(issue):
    """Map an issue to a task type for the trust ledger. Adapt to your labels/titles."""
    title = issue["title"].lower()
    if "bump" in title or "dependency" in title:
        return "dep-bump"
    if "flaky" in title:
        return "flaky-fix"
    return "general"


def run():
    # Re-verify yesterday's finished work first (Layer 05). A regression files its own issue.
    subprocess.run(["bash", str(OPS / "verify-goals.sh")])

    for issue in open_work():
        task = f"#{issue['number']} {issue['title']}"

        # Budget gate (Layer 06) — a number, not a judgment, can end the day.
        if subprocess.run(["bash", str(OPS / "budget-guard.sh")]).returncode != 0:
            print("budget cap reached — stopping"); break

        # DECIDE (Fable, higher effort): is this safe to run unattended right now?
        verdict = ask("decide",
                      f"Task: {task}\n\n{quarantine(issue['body'])}\n\n"
                      "Given the constitution, reply RUN or HOLD and one line why.",
                      effort=8000)
        if not verdict.strip().upper().startswith("RUN"):
            print(f"HOLD {task}: {verdict.strip().splitlines()[0]}"); continue

        if DRY_RUN:
            plan = ask("decide", f"Plan task {task}. Numbered steps only.", effort=6000)
            print(f"[dry-run] would execute:\n{plan}\n"); continue

        # PLAN (Fable) → EXECUTE (cheap tier) → JUDGE + final vote (script). Layers 02–04.
        branch = f"agent/{issue['number']}"
        subprocess.run(["git", "checkout", "-B", branch])
        plan = ask("decide", f"Plan task {task}. Numbered steps only.", effort=6000)
        _run_executor(plan)                       # cheap tier does the typing

        gate = subprocess.run(["bash", str(OPS / "check-constitution.sh")])
        ttype = task_type_of(issue)
        if gate.returncode == 0 and trust.may_run_unattended(ttype):
            subprocess.run(["git", "commit", "-am", f"agent: {task}"])
            trust.record(ttype, True)
            print(f"committed {task} (unattended, type={ttype})")
        else:
            _open_pr_for_human(issue, plan)
            trust.record(ttype, gate.returncode == 0)  # passed the gate but not yet trusted → still a good run
            print(f"PR opened for {task} (gate={'pass' if gate.returncode == 0 else 'fail'}, type={ttype})")


def _run_executor(plan):
    """Hand the plan to the cheapest tier via Claude Code. Replace with your own runner."""
    subprocess.run([
        "claude", "-p", f"Execute this plan exactly. Do not improvise scope.\n\n{plan}",
        "--model", M["execute"],
    ])


def _open_pr_for_human(issue, plan):
    subprocess.run(["git", "push", "-u", "origin", f"agent/{issue['number']}"])
    subprocess.run(["gh", "pr", "create", "--fill",
                    "--title", f"agent: {issue['title']}",
                    "--body", f"Automated attempt for #{issue['number']}.\n\nPlan:\n{plan}"])


if __name__ == "__main__":
    run()
