#!/usr/bin/env python3
"""LFCS WhatsApp Cloud webhook — crew site-diary capture.

Meta pushes each inbound WhatsApp message here. This service:
  - answers Meta's GET verification handshake,
  - on POST, logs the message + fetches any photo/voice media (saved to disk),
  - sends a ONE-LINE nudge when a photo lands with no caption ("what task?").

Quiet by design: it only speaks the nudge, nothing else — the group is shared,
everyone's watching. Config (incl. the access token) lives in wa_config.json
(chmod 600), NEVER in this file. Runs as a systemd service behind nginx
(/wa-hook/<suffix> -> 127.0.0.1:8450).

Every POST is signature-checked against Meta's X-Hub-Signature-256 before it is
parsed or acted on (see do_POST). No valid signature, no handle() call, 403.

Stage: v1 = receive + save media + nudge. Diary write (transcribe/vision ->
Airtable Daily Record) is the next layer, built on real captured data.
"""
import json, os, datetime, hmac, hashlib, urllib.request, urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer

CONF_PATH = "/root/.hermes/wa_config.json"
LOG = "/root/.hermes/logs/wa_webhook.log"
MEDIA_DIR = "/root/.hermes/wa_media"
PORT = 8450
os.makedirs(MEDIA_DIR, exist_ok=True)

EXT = {"image": "jpg", "audio": "ogg", "voice": "ogg", "video": "mp4", "document": "bin"}

# --- Airtable Tools & Materials register (base appE43UvTyARe5oJs) ---
AT_BASE = "appE43UvTyARe5oJs"
AT_MOVEMENT = "tbl8dT8HcPzFu3HlS"   # Movement Log
AT_NOTES = "fldrE1otZ6pBZaB7I"       # Notes (text) — v1 raw capture lands here
HERMES_CONFIG_YAML = "/root/.hermes/config.yaml"


def conf():
    return json.load(open(CONF_PATH))


def airtable_pat():
    import yaml
    return yaml.safe_load(open(HERMES_CONFIG_YAML))["mcp_servers"]["airtable"]["env"]["AIRTABLE_API_KEY"]


def _env(k):
    for line in open("/root/.hermes/.env"):
        if line.startswith(k + "="):
            return line.split("=", 1)[1].strip()
    return ""


def app_secret():
    """The Meta App Secret used to sign inbound webhooks. wa_config.json "app_secret" first
    (wa_subscribe.py already writes it there), /root/.hermes/.env WA_APP_SECRET second.
    Never hardcoded here, never printed, never logged, not even a few characters of it: a
    truncated secret in a logfile is still a lead, and this log is read by tooling. Returns
    "" when nothing is configured, and "" means REFUSE, never "skip the check"."""
    try:
        s = (conf().get("app_secret") or "").strip()
    except Exception:
        s = ""
    if not s:
        try:
            s = _env("WA_APP_SECRET")
        except Exception:
            s = ""
    return s


def verify_sig(raw, header, secret):
    """True if `header` (Meta's X-Hub-Signature-256) is HMAC-SHA256 of the RAW body bytes
    keyed with the app secret. Must be the bytes off the wire, before json.loads: any
    re-encode reorders or respaces the JSON and the digest stops matching. compare_digest,
    not ==, because == returns on the first wrong byte and leaks the right prefix to anyone
    timing the replies."""
    if not header or not header.startswith("sha256="):
        return False
    mine = hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(mine, header.split("=", 1)[1].strip())


PARSE_SYS = (
    "You are a construction tools and materials clerk for LFCS. Extract a JSON object "
    "from the site message. Schema: {\"job\": string|null (LFCS job number like \"2631\" "
    "if present), \"direction\": \"out\"|\"in\"|null (out=drawn/taken to job, in=returned "
    "to store), \"items\":[{\"item\": string, \"qty\": number|null, \"unit\": string|null}]}. "
    "Tradie knowledge: 4x2=90x45 timber, ply=plywood sheet, gen=generator, drop saw=mitre saw. "
    "Return ONLY JSON."
)


def parse_tm(msg):
    """Low-sensitivity T&M parse via DeepSeek (OpenAI-compatible). Returns dict or None.
    T&M is not commercial-sensitive (no bids/rates/margins), so the cheap lane is fine."""
    key = _env("DEEPSEEK_API_KEY")
    if not key:
        return None
    body = json.dumps({"model": "deepseek-chat", "temperature": 0,
                       "response_format": {"type": "json_object"},
                       "messages": [{"role": "system", "content": PARSE_SYS},
                                    {"role": "user", "content": msg}]}).encode()
    r = urllib.request.Request("https://api.deepseek.com/v1/chat/completions", data=body,
                               headers={"Authorization": "Bearer " + key,
                                        "Content-Type": "application/json"})
    try:
        out = json.loads(urllib.request.urlopen(r, timeout=30).read().decode())
        return json.loads(out["choices"][0]["message"]["content"])
    except Exception as e:
        log("PARSE ERR %s" % e)
        return None


def log_movement(note):
    """v1 T&M capture — raw text into a Movement Log row's Notes. The office (or a
    later smart-parse layer) structures it into tool/material/qty. Marked unverified."""
    try:
        body = json.dumps({"fields": {AT_NOTES: note}, "typecast": True}).encode()
        r = urllib.request.Request("https://api.airtable.com/v0/%s/%s" % (AT_BASE, AT_MOVEMENT),
                                   data=body, method="POST",
                                   headers={"Authorization": "Bearer " + airtable_pat(),
                                            "Content-Type": "application/json"})
        res = json.loads(urllib.request.urlopen(r, timeout=25).read().decode())
        log("MOVEMENT row %s" % res.get("id"))
    except Exception as e:
        log("MOVEMENT ERR %s" % e)


def log(msg):
    line = "%s %s" % (datetime.datetime.now().isoformat(timespec="seconds"), msg)
    with open(LOG, "a") as f:
        f.write(line + "\n")


def graph_get(url, token):
    r = urllib.request.Request(url, headers={"Authorization": "Bearer " + token})
    return urllib.request.urlopen(r, timeout=30).read()


def fetch_media(media_id, mtype, token, gv):
    meta = json.loads(graph_get("https://graph.facebook.com/%s/%s" % (gv, media_id), token))
    data = graph_get(meta["url"], token)
    stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
    path = os.path.join(MEDIA_DIR, "%s_%s.%s" % (stamp, media_id, EXT.get(mtype, "bin")))
    with open(path, "wb") as f:
        f.write(data)
    return path, len(data)


def send_text(to, body, c):
    if not c.get("access_token"):
        log("NUDGE skipped (no token yet) -> %s" % to)
        return
    payload = json.dumps({"messaging_product": "whatsapp", "to": to,
                          "type": "text", "text": {"body": body}}).encode()
    url = "https://graph.facebook.com/%s/%s/messages" % (c["graph_version"], c["phone_number_id"])
    r = urllib.request.Request(url, data=payload, method="POST",
                              headers={"Authorization": "Bearer " + c["access_token"],
                                       "Content-Type": "application/json"})
    try:
        urllib.request.urlopen(r, timeout=30)
        log("NUDGE sent -> %s" % to)
    except Exception as e:
        log("NUDGE ERR %s" % e)


def handle(payload, c):
    for entry in payload.get("entry", []):
        for ch in entry.get("changes", []):
            v = ch.get("value", {})
            for m in v.get("messages", []):
                frm = m.get("from")
                mtype = m.get("type")
                ts = m.get("timestamp")
                text = caption = ""
                media_id = None
                if mtype == "text":
                    text = m.get("text", {}).get("body", "")
                elif mtype in EXT:
                    node = m.get(mtype, {})
                    media_id = node.get("id")
                    caption = node.get("caption", "")
                saved = ""
                if media_id and c.get("access_token"):
                    try:
                        p, n = fetch_media(media_id, mtype, c["access_token"], c["graph_version"])
                        saved = " saved=%s(%dB)" % (p, n)
                    except Exception as e:
                        saved = " MEDIA_ERR=%s" % e
                log("MSG from=%s type=%s ts=%s text=%r caption=%r media=%s%s"
                    % (frm, mtype, ts, text, caption, media_id, saved))
                # T&M capture (v1.5): LLM-parse tradie shorthand into a clean structured note.
                # We DO NOT write to the Movement Log's structured link fields (thin catalogue +
                # ambiguous job/location fields) — the parse lands legible in Notes marked CHECK,
                # for the office to promote. Source-of-truth: no guessed field writes.
                raw = text or caption or ("[%s]" % mtype)
                photo = ("\nphoto on box: " + saved.split("saved=")[-1].split("(")[0]
                         if saved.startswith(" saved=") else "")
                parsed = parse_tm(raw) if (text or caption) else None
                if parsed:
                    items = "; ".join(
                        (("%s " % it["qty"] if it.get("qty") is not None else "")
                         + ((it.get("unit") + " ") if it.get("unit") else "")
                         + (it.get("item") or "")).strip()
                        for it in parsed.get("items", [])) or "(no items read)"
                    job = parsed.get("job") or "??"
                    dirn = {"out": "OUT (drawn to job)", "in": "IN (returned)"}.get(
                        parsed.get("direction"), "?")
                    note = ("AI-parsed (CHECK) — Job %s | %s | %s\nraw: %s | from +%s%s"
                            % (job, dirn, items, raw, frm, photo))
                else:
                    note = ("WhatsApp T&M capture (unverified) — from +%s\n%s%s"
                            % (frm, raw, photo))
                log_movement(note)
                # quiet single nudge: missing job first, else captionless photo
                if parsed and not parsed.get("job"):
                    send_text(frm, "Got it. Which job's it for?", c)
                elif mtype in ("image", "video") and not caption:
                    send_text(frm, c.get("nudge_text", "What job + tool/material is this?"), c)


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass  # silence default stderr logging

    def do_GET(self):
        q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
        mode = q.get("hub.mode", [""])[0]
        tok = q.get("hub.verify_token", [""])[0]
        ch = q.get("hub.challenge", [""])[0]
        if mode == "subscribe" and tok == conf().get("verify_token"):
            self.send_response(200)
            self.end_headers()
            self.wfile.write(ch.encode())
            log("VERIFY ok")
        else:
            self.send_response(403)
            self.end_headers()
            log("VERIFY fail (tok mismatch)")

    def do_POST(self):
        n = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(n)
        # AUTH GATE, added 2026-08-06. Until this went in, do_POST read the body, answered
        # 200 and called handle() with ZERO authentication. handle() ends in send_text(frm,
        # ...) and frm comes straight off the inbound payload, so anyone who could POST at
        # this endpoint could make the LFCS business WhatsApp number send a message to any
        # number they chose: an unauthenticated relay wearing the company's identity, with
        # the company wearing whatever it said. The one thing standing in the way was Meta's
        # 5-recipient cap on an unverified app. That is someone else's setting on someone
        # else's dashboard and it evaporates the hour the app goes Live. The URL suffix is
        # not a defence either, it is not a secret and it sits in the Meta dashboard and in
        # nginx logs. hub.verify_token on the GET is Meta's one-time subscription handshake,
        # nothing to do with per-request signing.
        # So: every POST must carry Meta's X-Hub-Signature-256 over the raw body, keyed with
        # the App Secret. Fail closed at both gates. No secret configured means refuse the
        # lot and shout in the log, because a missing secret is a broken deploy, not a
        # licence to accept unsigned traffic.
        secret = app_secret()
        if not secret:
            self.send_response(403)
            self.end_headers()
            log("POST REFUSED: no app_secret in wa_config.json and no WA_APP_SECRET in .env "
                "- webhook is deaf until one is set (set it, then restart wa-webhook)")
            return
        if not verify_sig(body, self.headers.get("X-Hub-Signature-256", ""), secret):
            self.send_response(403)
            self.end_headers()
            log("POST REJECTED: bad or missing X-Hub-Signature-256 (%d bytes from %s)"
                % (n, self.client_address[0]))
            return
        self.send_response(200)  # ack fast — Meta retries on non-200 (VERIFIED requests only)
        self.end_headers()
        try:
            handle(json.loads(body), conf())
        except Exception as e:
            log("POST ERR %s" % e)


if __name__ == "__main__":
    log("wa_webhook starting on :%d" % PORT)
    HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
