#!/usr/bin/env python3
"""pm-heartbeat — morning digest builder.
Read-only to Airtable business data. The man-hours watchdog half is a
separate deployed cron script and is NOT run from this agentic path.
"""
import json
import urllib.request
import urllib.parse
from datetime import date, datetime, timedelta, timezone

PAT = "patTsSy5GfSYklTjm.e4367503475580b67972135a7cb0569da6a6a955e7bc5491873ea413bbc25ddbd7".replace("e43675", "e43675")  # noop, the literal above is the key as stored
# Re-read cleanly from config to avoid eyeball slips
import re
with open("/root/.hermes/config.yaml") as f:
    cfg = f.read()
m = re.search(r"AIRTABLE_API_KEY:\s*(\S+)", cfg)
PAT = m.group(1).strip()

BASE = "appE43UvTyARe5oJs"
ACTIONS = "tblwGQdNqZIPRiDSV"
JOBS = "tblcL2EX2w34c1VPe"
DAILY = "tbli76T3GvmLSd3Kd"

# Sydney-local today
import os, time
os.environ["TZ"] = "Australia/Sydney"
time.tzset()
TODAY = date.today()
print(f"# today = {TODAY} ({TODAY.strftime('%A')})", flush=True)

# ---------- helpers ----------
def get_json(url):
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {PAT}"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read())

def list_all(table, fields=None, page_size=100, filter_formula=None):
    out = []
    offset = None
    qs = {"pageSize": str(page_size)}
    if fields:
        qs["fields[]"] = fields
    if filter_formula:
        qs["filterByFormula"] = filter_formula
    while True:
        q = dict(qs)
        if offset:
            q["offset"] = offset
        url = f"https://api.airtable.com/v0/{BASE}/{table}?{urllib.parse.urlencode(q, doseq=True)}"
        data = get_json(url)
        out.extend(data.get("records", []))
        offset = data.get("offset")
        if not offset:
            break
    return out

def get_record(table, rid):
    url = f"https://api.airtable.com/v0/{BASE}/{table}/{rid}"
    return get_json(url)

# ---------- pull jobs (small) ----------
print("# pulling Jobs", flush=True)
# Must include "Actions" in the field list or the linked-record IDs are stripped
jobs = list_all(JOBS, fields=["Job Number", "Status", "Lifecycle Status", "Client", "Name", "Diary Channel ID", "Actions"])
job_by_rid = {j["id"]: j for j in jobs}

# Build job# lookup: rid -> job# string
def jobnum(rid):
    j = job_by_rid.get(rid)
    if not j: return None
    return j["fields"].get("Job Number")

# Collect all Action record IDs grouped by job rid
actions_by_job = {}
all_action_ids = []
for j in jobs:
    aids = j["fields"].get("Actions", [])
    if aids:
        actions_by_job[j["id"]] = aids
        all_action_ids.extend(aids)
all_action_ids = list(dict.fromkeys(all_action_ids))
print(f"# {len(all_action_ids)} unique action record ids across {len(actions_by_job)} jobs", flush=True)

# ---------- pull actions per-id (avoids truncation) ----------
print("# pulling actions per-id", flush=True)
act_records = []
fail = 0
for rid in all_action_ids:
    try:
        a = get_record(ACTIONS, rid)
        act_records.append(a)
    except Exception as e:
        fail += 1
        # skip — never abort
print(f"# pulled {len(act_records)} actions, {fail} failed", flush=True)

# ---------- pull daily records (sort in python) ----------
print("# pulling Daily Records", flush=True)
dailies = list_all(DAILY, fields=["Diary Ref", "Date", "Job"])
# group by job rid
daily_by_job = {}
for d in dailies:
    jlink = d["fields"].get("Job")
    if not jlink: continue
    jrid = jlink[0] if isinstance(jlink, list) else jlink
    dstr = d["fields"].get("Date")
    if not dstr: continue
    try:
        ddate = datetime.fromisoformat(dstr.replace("Z", "+00:00")).date()
    except Exception:
        continue
    daily_by_job.setdefault(jrid, []).append(ddate)

latest_daily_by_job = {jr: max(ds) for jr, ds in daily_by_job.items()}
print(f"# daily records for {len(latest_daily_by_job)} jobs", flush=True)

# ---------- classify ----------
def working_days_ago(d, today=TODAY):
    """Weekdays strictly before today, up to and including d."""
    if d >= today: return 0
    n = 0
    cur = d
    while cur < today:
        if cur.weekday() < 5:
            n += 1
        cur += timedelta(days=1)
    return n

# work out wd-3 (bid-phase) and wd-7 (other) thresholds
def workdays_before(today, n):
    """Return the date that is `n` working days before today."""
    cur = today
    seen = 0
    while True:
        cur -= timedelta(days=1)
        if cur.weekday() < 5:
            seen += 1
            if seen == n:
                return cur

wd3 = workdays_before(TODAY, 3)
wd7 = workdays_before(TODAY, 7)
print(f"# wd3 cutoff = {wd3}, wd7 cutoff = {wd7}", flush=True)

# Determine bid-phase: Lifecycle Status in {Bid, Bidding, Tendering, Negotiation, Negotiating, Bid Submitted}
# OR a Job status hint. Use Lifecycle Status if present, else Status.
BID_LIFECYCLE = {"Bid", "Bidding", "Tendering", "Negotiating", "Negotiation", "Bid Submitted"}
def job_phase(jrid):
    j = job_by_rid.get(jrid)
    if not j: return "other"
    lc = (j["fields"].get("Lifecycle Status") or "").strip()
    if lc in BID_LIFECYCLE: return "bid"
    st = (j["fields"].get("Status") or "").strip()
    if st in {"Bid", "Tendering", "Bid Submitted", "Negotiating"}: return "bid"
    return "other"

def parse_date(s):
    if not s: return None
    try:
        return datetime.fromisoformat(s.replace("Z", "+00:00")).date()
    except Exception:
        return None

# Build per-action line candidates
overdue = []   # (sort_key, line)  sort_key = due date asc (older first)
aged = []      # (sort_key, line)  sort_key = waiting since asc
due7 = []      # (sort_key, line)  sort_key = due date asc
quiet = []     # (sort_key, line)  sort_key = silence days desc

OPEN_WAITING = {"Open", "Waiting"}

# Filter for non-meta (Action subject doesn't start with WATCHDOG: — those are system rows, not PM work)
def is_meta(action_text):
    if not action_text: return False
    t = action_text.strip().upper()
    return t.startswith("WATCHDOG:")

for a in act_records:
    f = a.get("fields", {})
    status = f.get("Status")
    if status not in OPEN_WAITING:
        continue
    text = (f.get("Action") or "").strip()
    if is_meta(text):
        continue
    due = parse_date(f.get("Due"))
    ws = parse_date(f.get("Waiting Since"))
    jlinks = f.get("Job")
    jrid = jlinks[0] if isinstance(jlinks, list) and jlinks else None
    jn = jobnum(jrid) if jrid else None
    jname = (job_by_rid.get(jrid, {}).get("fields", {}).get("Name", "") if jrid else "")

    # Clean action text: strip leading "<job#>: " if present (we're prefixing job#)
    short = text
    if jn and short.startswith(f"{jn}: "):
        short = short[len(f"{jn}: "):]
    elif jn and short.startswith(f"{jn} "):
        short = short[len(f"{jn} "):]
    if len(short) > 75:
        short = short[:72] + "..."

    due_str = due.strftime("%a %-d/%-m") if due else "—"

    # OVERDUE: due < today
    if due and due < TODAY:
        overdue.append((due, f"{jn or '?'} {short} — due {due_str}"))
    # AGED WAITING: status=Waiting, ws > wd3 (bid) or wd7 (other)
    elif status == "Waiting" and ws:
        phase = job_phase(jrid) if jrid else "other"
        cutoff = wd3 if phase == "bid" else wd7
        if ws < cutoff:  # ws is older than cutoff
            age_wd = working_days_ago(ws)
            aged.append((ws, f"{jn or '?'} {short} — waiting {age_wd}d"))
    # DUE <=7d: 0 <= due-today <= 7
    if due and 0 <= (due - TODAY).days <= 7:
        due7.append((due, f"{jn or '?'} {short} — due {due_str}"))

# QUIET: live job (Lifecycle On-site/In-Progress/Delivering) with no DR in last 3 working days
LIVE_LIFECYCLE = {"On-site", "In-Progress", "Delivering", "On Site"}
LIVE_STATUS = {"On-site", "On Site", "Delivering", "In-Progress"}
def is_live(jrid):
    j = job_by_rid.get(jrid)
    if not j: return False
    lc = (j["fields"].get("Lifecycle Status") or "").strip()
    if lc in LIVE_LIFECYCLE: return True
    st = (j["fields"].get("Status") or "").strip()
    if st in LIVE_STATUS: return True
    return False

# Per skill: QUIET band is "replaced by the man-hours watchdog" — but the digest's recipe
# (digest-packing.md) still defines the QUIET band for backwards compat. The watchdog
# lives in the register, not Telegram. Use the man-hours-master-blind fallback only for
# jobs the system diarises (i.e. have Diary Channel ID).
for j in jobs:
    jf = j["fields"]
    jn = jf.get("Job Number")
    jn_s = str(jn) if jn else ""
    if not is_live(j["id"]): continue
    if not jf.get("Diary Channel ID"): continue  # not in diary set
    latest = latest_daily_by_job.get(j["id"])
    if latest is None:
        # never had a DR — surface as a one-off
        quiet.append((datetime(1900,1,1), f"{jn} no diary filed"))
    else:
        silence = (TODAY - latest).days
        wd_silence = working_days_ago(latest)
        if wd_silence >= 3:
            quiet.append((datetime(1900,1,1) - timedelta(days=silence), f"{jn} no diary {silence}d"))

# ---------- sort bands ----------
for band in (overdue, aged, due7, quiet):
    band.sort(key=lambda t: t[0])

# ---------- pack ----------
BUDGET = 15
counts = {"overdue": len(overdue), "aged": len(aged), "due7": len(due7), "quiet": len(quiet)}
print(f"# band counts: {counts}", flush=True)

hour = int(os.popen("date +%H").read().strip())  # local hour in Sydney (tzset above)
minute = int(os.popen("date +%M").read().strip())

# "X of N" honest header form so the cap math is visible
shown = {"overdue": 0, "aged": 0, "due7": 0, "quiet": 0}  # filled by allocate
lines = [f"PM {TODAY.strftime('%a %-d/%-m')} {hour:02d}:{minute:02d} — header placeholder"]
lines = []  # we'll prepend the header after we know allocation

band_order = ["overdue", "aged", "due7", "quiet"]
band_label = {"overdue": "🔴 OVERDUE:", "aged": "🔴 AGED WAITING:", "due7": "🟡 DUE ≤7d:", "quiet": "⚪ QUIET:"}
buckets = {"overdue": overdue, "aged": aged, "due7": due7, "quiet": quiet}

def allocate(max_lines, totals, present):
    n_labels = sum(present)
    per = []
    for i, t in enumerate(totals):
        if not present[i] or t == 0:
            per.append(0)
        else:
            per.append(min(5, t))
    placeholders = sum(1 for i in range(4) if present[i] and per[i] < totals[i])
    # iterate
    while True:
        placeholders = sum(1 for i in range(4) if present[i] and per[i] < totals[i])
        labels_actual = sum(present) - (1 if per[3] == 1 and present[3] else 0)  # QUIET fold saves 1
        total_used = 1 + labels_actual + sum(per) + placeholders
        if total_used <= max_lines:
            break
        idx = max((i for i in range(4) if present[i] and per[i] > 1), key=lambda i: per[i], default=None)
        if idx is None:
            break
        per[idx] -= 1
    return per

totals = [counts[k] for k in band_order]
present = [bool(buckets[k]) for k in band_order]
per_band = allocate(BUDGET, totals, present)
print(f"# per_band = {per_band}", flush=True)

# Build the "X of N" honest header
hdr = f"PM {TODAY.strftime('%a %-d/%-m')} {hour:02d}:{minute:02d}"
parts = []
labels_short = {"overdue": "overdue", "aged": "aged-waiting", "due7": "due<=7d", "quiet": "quiet"}
for i, k in enumerate(band_order):
    if counts[k] == 0: continue
    shown_n = per_band[i]
    if shown_n < counts[k] and shown_n > 0:
        parts.append(f"{shown_n} of {counts[k]} {labels_short[k]}")
    else:
        parts.append(f"{counts[k]} {labels_short[k]}")
if parts:
    hdr += " — " + ", ".join(parts)
else:
    hdr += " — register clean or register blind — /pm to verify"
lines.append(hdr)

for k, n in zip(band_order, per_band):
    bucket = buckets[k]
    if not bucket: continue
    if k == "quiet" and n == 1:
        lines.append(f"⚪ QUIET: {bucket[0][1]}")
        if len(lines) >= BUDGET: break
        continue
    lines.append(band_label[k])
    for _, line in bucket[:n]:
        lines.append(line)
        if len(lines) >= BUDGET - 1: break
    if len(bucket) > n and len(lines) < BUDGET:
        lines.append(f"(+{len(bucket) - n} more)")
    if len(lines) >= BUDGET: break

# Hard cap
out = "\n".join(lines[:BUDGET])
print("=" * 60, flush=True)
print(out, flush=True)
print("=" * 60, flush=True)

# Save debug
with open("/tmp/pmh_heartbeat.json", "w") as f:
    json.dump({
        "today": str(TODAY),
        "counts": counts,
        "per_band": per_band,
        "lines": lines[:BUDGET],
        "overdue_items": [t[1] for t in overdue],
        "aged_items": [t[1] for t in aged],
        "due7_items": [t[1] for t in due7],
        "quiet_items": [t[1] for t in quiet],
    }, f, indent=2)
