"""Approve an agent's held API writes from your own tooling.

Python 3.12+, standard library only. Receives signed pending-action webhooks
(or polls when webhooks cannot reach you), shows the exact held call, and
resolves it in the terminal or from Slack buttons.

The platform records the owner of DT_API_KEY as the approver, so run this
with that person's own personal key and restrict who can click in Slack.
"""

import argparse
from contextlib import closing
from datetime import datetime
import getpass
import hashlib
import hmac
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import queue
import sqlite3
import sys
import threading
import time
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, quote
from urllib.request import Request, urlopen
import uuid


EVENT_TYPES = ["agent.pending_action.*"]
SIGNATURE_TOLERANCE_SECONDS = 300
MAX_EVENT_BYTES = 256 * 1024
USER_AGENT = "dt-governed-writes-example/1.0"


class ApiError(RuntimeError):
    def __init__(self, status, payload):
        self.status, self.payload = status, payload
        detail = payload.get("detail") or payload.get("error") if isinstance(payload, dict) else payload
        super().__init__(f"HTTP {status}: {detail}")


# --- Configuration and local state -------------------------------------------

class Config:
    def __init__(self, args):
        self.api_base = os.environ.get("DT_API_BASE", "http://localhost:8000/api/v1").rstrip("/")
        self.agent_id = str(uuid.UUID(os.environ.get("DT_AGENT_ID") or input("Agent ID: ").strip()))
        self.api_key = os.environ.get("DT_API_KEY") or getpass.getpass("Personal API key (approver): ")
        self.state_dir = Path(args.state_dir)
        self.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)

    @property
    def secret_path(self):
        return self.state_dir / "webhook-signing-secret"

    def webhook_secret(self):
        secret = os.environ.get("DT_WEBHOOK_SECRET")
        if secret:
            return secret
        if self.secret_path.exists():
            return self.secret_path.read_text().strip()
        sys.exit("No webhook signing secret. Run `subscribe` first or set DT_WEBHOOK_SECRET.")


def open_inbox(config):
    db = sqlite3.connect(config.state_dir / "inbox.sqlite3", check_same_thread=False)
    db.execute("CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, body BLOB NOT NULL, received_at TEXT NOT NULL)")
    db.execute("CREATE TABLE IF NOT EXISTS decisions (action_id TEXT PRIMARY KEY, decision TEXT NOT NULL, "
               "idempotency_key TEXT NOT NULL, decided_by TEXT NOT NULL, decided_at TEXT NOT NULL)")
    db.commit()
    return db


# --- Platform API -------------------------------------------------------------

def api(config, method, path, body=None, idempotency_key=None):
    headers = {"X-Api-Key": config.api_key, "Accept": "application/json", "User-Agent": USER_AGENT}
    data = None
    if body is not None:
        data = json.dumps(body).encode()
        headers["Content-Type"] = "application/json"
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    request = Request(f"{config.api_base}{path}", data=data, headers=headers, method=method)
    try:
        with urlopen(request, timeout=30) as response:
            raw = response.read()
            return json.loads(raw) if raw else {}
    except HTTPError as exc:
        raw = exc.read()
        try:
            payload = json.loads(raw)
        except ValueError:
            payload = {"detail": raw[:300].decode(errors="replace")}
        raise ApiError(exc.code, payload) from None


def pending_actions(config):
    return api(config, "GET", f"/agents/{config.agent_id}/pending-actions/").get("actions", [])


def current_action(config, action_id):
    """Event snapshots can be stale or replayed; always decide on current state."""
    return next((a for a in pending_actions(config) if a["id"] == action_id), None)


def resolve(config, db, action_id, decision, decided_by):
    # Reuse the key for the same decision so a retry after a lost response replays it.
    row = db.execute("SELECT decision, idempotency_key FROM decisions WHERE action_id = ?", (action_id,)).fetchone()
    if row and row[0] != decision:
        raise ApiError(409, {"detail": f"already recorded a different local decision: {row[0]}"})
    key = row[1] if row else str(uuid.uuid4())
    if not row:
        db.execute("INSERT INTO decisions VALUES (?, ?, ?, ?, ?)",
                   (action_id, decision, key, decided_by, datetime.now().isoformat(timespec="seconds")))
        db.commit()
    path = f"/agents/{config.agent_id}/pending-actions/{quote(action_id, safe=':')}/resolve/"
    try:
        return api(config, "POST", path, {"decision": decision}, idempotency_key=key)["action"]
    except ApiError as exc:
        # Replay protection for decisions is deployment-gated. Without it, an OpenAPI
        # approval is still single-use: a repeated decision returns 409, never a second call.
        if exc.status != 503 or (exc.payload or {}).get("code") != "idempotency_unavailable":
            raise
    return api(config, "POST", path, {"decision": decision})["action"]


# --- Presentation -------------------------------------------------------------

def color(text, code):
    return f"\033[{code}m{text}\033[0m" if sys.stdout.isatty() else text


def describe(action):
    data = action.get("data") or {}
    if action["type"] == "openapi_approval":
        args = json.dumps(data.get("arguments_preview") or {}, indent=2).replace("\n", "\n" + " " * 15)
        return [
            ("Integration", data.get("integration_name", "")),
            ("Call", f"{data.get('method', '')} {data.get('path', '')}   ({data.get('tool_name', '')})"),
            ("Arguments", args),
            ("Expires", action.get("expires_at") or "never"),
        ]
    if action["type"] == "human_input":
        return [("Question", data.get("question", ""))]
    return [("Data", json.dumps(data)[:400])]


def print_card(action):
    title = "Approval needed" if action["type"] == "openapi_approval" else f"Pending {action['type']}"
    print()
    print(color(f"── {title} " + "─" * max(4, 56 - len(title)), "1;33"))
    print(f"  {'Action':<12} {action['id']}")
    for label, value in describe(action):
        print(f"  {label:<12} {value}")
    print(color("─" * 60, "33"))


def report(resolved):
    status = resolved.get("status")
    tone = "1;32" if status == "approved" else "1;31"
    print(color(f"  -> {status}", tone), end="")
    if status == "approved":
        print("  The platform now executes exactly this call with the integration's credential.")
    else:
        print("  The agent is told not to run this call and continues without it.")


# --- Terminal review ----------------------------------------------------------

def review(config, db, action_id, assume=None):
    action = current_action(config, action_id)
    if action is None:
        print(f"  {action_id} is no longer pending (already decided, expired, or not visible to you).")
        return
    print_card(action)
    if action["type"] != "openapi_approval":
        print("  This example only decides OpenAPI approvals; use the console for other action types.")
        return
    decision = assume
    while decision is None:
        answer = input("  Approve this exact call? [a]pprove / [d]ecline / [s]kip: ").strip().lower()
        decision = {"a": "approve", "d": "decline", "s": "skip"}.get(answer[:1])
    if decision == "skip":
        return
    try:
        report(resolve(config, db, action_id, decision, decided_by=f"terminal:{getpass.getuser()}"))
    except ApiError as exc:
        print(color(f"  Not resolved: {exc}", "31"))


# --- Webhook receiver ---------------------------------------------------------

def valid_agent_signature(raw_body, signature_header, secret, *, now=None):
    try:
        fields = dict(part.split("=", 1) for part in signature_header.split(","))
        timestamp, received = fields["t"], fields["v1"]
        if not (timestamp.isascii() and timestamp.isdigit()) or len(received) != 64:
            return False
        if abs((time.time() if now is None else now) - int(timestamp)) > SIGNATURE_TOLERANCE_SECONDS:
            return False
    except (ValueError, KeyError):
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)


def valid_slack_signature(raw_body, timestamp, signature, secret, *, now=None):
    if not timestamp.isdigit() or abs((time.time() if now is None else now) - int(timestamp)) > SIGNATURE_TOLERANCE_SECONDS:
        return False
    expected = "v0=" + hmac.new(secret.encode(), b"v0:" + timestamp.encode() + b":" + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


def accept_event(db, lock, raw_body, signature_header, header_event_id, secret):
    """Verify, then durably record before acting. Returns (http_status, envelope or None)."""
    if not valid_agent_signature(raw_body, signature_header or "", secret):
        return 401, None
    try:
        envelope = json.loads(raw_body)
        event_id = str(uuid.UUID(envelope["id"]))
        if envelope["version"] != "1" or event_id != header_event_id:
            return 400, None
    except (ValueError, KeyError, TypeError):
        return 400, None
    with lock:
        inserted = db.execute("INSERT OR IGNORE INTO inbox VALUES (?, ?, ?)",
                              (event_id, raw_body, datetime.now().isoformat(timespec="seconds"))).rowcount
        db.commit()
        existing = db.execute("SELECT body FROM inbox WHERE id = ?", (event_id,)).fetchone()
    if bytes(existing[0]) != raw_body:
        return 409, None
    return 204, envelope if inserted else None


class Slack:
    def __init__(self):
        self.token = os.environ["SLACK_BOT_TOKEN"]
        self.channel = os.environ["SLACK_CHANNEL"]
        self.signing_secret = os.environ["SLACK_SIGNING_SECRET"]
        # The platform attributes every decision to the API key's owner, so only
        # that person (or people they explicitly trust) may click.
        self.approvers = {u.strip() for u in os.environ["SLACK_APPROVER_USER_IDS"].split(",") if u.strip()}
        if not self.approvers:
            sys.exit("SLACK_APPROVER_USER_IDS must list at least one Slack user ID.")

    def call(self, method, payload):
        request = Request(f"https://slack.com/api/{method}", data=json.dumps(payload).encode(), headers={
            "Authorization": f"Bearer {self.token}", "Content-Type": "application/json; charset=utf-8",
            "User-Agent": USER_AGENT,
        })
        with urlopen(request, timeout=15) as response:
            result = json.loads(response.read())
        if not result.get("ok"):
            raise RuntimeError(f"Slack {method} failed: {result.get('error')}")
        return result

    @staticmethod
    def card_blocks(action, footer=None):
        lines = "\n".join(f"*{label}:* {value if label != 'Arguments' else ''}" for label, value in describe(action))
        args = json.dumps((action.get("data") or {}).get("arguments_preview") or {}, indent=2)
        blocks = [
            {"type": "section", "text": {"type": "mrkdwn", "text": f":lock: *Agent wants to make a held API call*\n{lines}"}},
            {"type": "section", "text": {"type": "mrkdwn", "text": f"```{args}```"}},
        ]
        if footer:
            blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": footer}]})
        else:
            blocks.append({"type": "actions", "elements": [
                {"type": "button", "style": "primary", "text": {"type": "plain_text", "text": "Approve"},
                 "action_id": "approve", "value": action["id"]},
                {"type": "button", "style": "danger", "text": {"type": "plain_text", "text": "Decline"},
                 "action_id": "decline", "value": action["id"]},
            ]})
        return blocks

    def post(self, action):
        data = action.get("data") or {}
        self.call("chat.postMessage", {
            "channel": self.channel,
            "text": f"Approval needed: {data.get('method')} {data.get('path')}",
            "blocks": self.card_blocks(action),
        })

    def respond(self, response_url, action, footer):
        body = json.dumps({"replace_original": True, "text": footer, "blocks": self.card_blocks(action, footer)})
        urlopen(Request(response_url, data=body.encode(), headers={
            "Content-Type": "application/json", "User-Agent": USER_AGENT}), timeout=15).close()


def serve(config, args):
    db, lock = open_inbox(config), threading.Lock()
    secret = config.webhook_secret()
    slack = Slack() if args.slack else None
    work = queue.Queue()

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, format, *args):
            pass

        def finish_with(self, status, body=b""):
            self.send_response(status)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def read_body(self):
            length = int(self.headers.get("Content-Length") or 0)
            return self.rfile.read(length) if 0 <= length <= MAX_EVENT_BYTES else None

        def do_POST(self):
            raw = self.read_body()
            if raw is None:
                return self.finish_with(413)
            if self.path == "/dt/events":
                status, envelope = accept_event(db, lock, raw, self.headers.get("X-DT-Signature"),
                                                self.headers.get("X-DT-Event-Id"), secret)
                self.finish_with(status)  # Acknowledge fast; decide on another thread.
                if envelope and envelope.get("type") == "agent.pending_action.created":
                    work.put(envelope["data"]["pending_action"]["id"])
                elif envelope:
                    action = envelope["data"]["pending_action"]
                    print(color(f"\n  {action['id']} -> {envelope['data'].get('resolution', {}).get('status')}", "2"))
                return
            if self.path == "/slack/actions" and slack:
                ok = valid_slack_signature(raw, self.headers.get("X-Slack-Request-Timestamp", ""),
                                           self.headers.get("X-Slack-Signature", ""), slack.signing_secret)
                if not ok:
                    return self.finish_with(401)
                self.finish_with(200)  # Slack needs an answer within 3 seconds.
                payload = json.loads(parse_qs(raw.decode())["payload"][0])
                threading.Thread(target=handle_slack_click, args=(payload,), daemon=True).start()
                return
            self.finish_with(404)

    def handle_slack_click(payload):
        try:
            decide_from_slack(payload)
        except (ApiError, URLError, RuntimeError, KeyError) as exc:
            print(color(f"  Slack decision failed: {exc}", "31"))

    def decide_from_slack(payload):
        user, click = payload["user"]["id"], payload["actions"][0]
        action_id, decision = click["value"], click["action_id"]
        if user not in slack.approvers or decision not in {"approve", "decline"}:
            print(color(f"  Slack user {user} is not an allowed approver; ignored.", "31"))
            return
        action = current_action(config, action_id)
        if action is None:
            footer = f"No longer pending (decided elsewhere or expired). Checked by <@{user}>."
            slack.respond(payload["response_url"], {"id": action_id, "type": "openapi_approval", "data": {}}, footer)
            return
        try:
            resolved = resolve(config, db, action_id, decision, decided_by=f"slack:{user}")
            footer = f"*{resolved['status'].title()}* by <@{user}> at {datetime.now():%H:%M:%S}"
        except ApiError as exc:
            footer = f"Not resolved: {exc}"
        slack.respond(payload["response_url"], action, footer)

    server = ThreadingHTTPServer((args.host, args.port), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    print(f"Listening on http://{args.host}:{args.port}/dt/events" + ("  and /slack/actions" if slack else ""))

    # Catch up on anything that arrived while we were down; webhooks are best-effort.
    for action in pending_actions(config):
        if action["type"] == "openapi_approval":
            work.put(action["id"])
    print("Waiting for held calls. Ctrl-C to stop.")

    seen = set()
    try:
        while True:
            action_id = work.get()
            if action_id in seen:
                continue
            seen.add(action_id)
            try:
                if slack:
                    action = current_action(config, action_id)
                    if action and action["type"] == "openapi_approval":
                        print_card(action)
                        slack.post(action)
                        print("  Posted to Slack.")
                else:
                    review(config, db, action_id)
            except (ApiError, URLError, RuntimeError) as exc:
                seen.discard(action_id)  # Retried on the next event or restart catch-up.
                print(color(f"  Could not present {action_id}: {exc}", "31"))
    except KeyboardInterrupt:
        server.shutdown()


# --- Commands -----------------------------------------------------------------

def cmd_subscribe(config, args):
    created = api(config, "POST", f"/agents/{config.agent_id}/webhooks/",
                  {"name": args.name, "url": args.url, "event_types": EVENT_TYPES},
                  idempotency_key=str(uuid.uuid4()))
    webhook = created.get("webhook", created)
    secret = webhook.get("signing_secret")
    if not secret:
        sys.exit("The create response did not include a signing secret.")
    with closing(os.fdopen(os.open(config.secret_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w")) as f:
        f.write(secret)
    print(f"Subscribed webhook {webhook.get('id')} to {', '.join(EVENT_TYPES)} at {args.url}")
    print(f"Signing secret saved to {config.secret_path} (mode 600).")


def cmd_list(config, args):
    actions = pending_actions(config)
    if not actions:
        print("Nothing pending.")
    for action in actions:
        print_card(action)


def cmd_decide(config, args):
    db = open_inbox(config)
    review(config, db, args.action_id, assume=None if args.command == "review" else args.command)


def cmd_watch(config, args):
    """Polling mode for laptops the platform cannot reach with a webhook."""
    db, seen = open_inbox(config), set()
    print(f"Polling every {args.interval}s. Ctrl-C to stop.")
    try:
        while True:
            for action in pending_actions(config):
                if action["type"] == "openapi_approval" and action["id"] not in seen:
                    seen.add(action["id"])
                    review(config, db, action["id"])
            time.sleep(args.interval)
    except KeyboardInterrupt:
        pass


def main():
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--state-dir", default=".approver-state")
    commands = parser.add_subparsers(dest="command", required=True)

    subscribe = commands.add_parser("subscribe", help="Create the agent's pending-action webhook")
    subscribe.add_argument("url", help="Public HTTPS URL that reaches `serve`, ending in /dt/events")
    subscribe.add_argument("--name", default="Governed writes approver")
    subscribe.set_defaults(handler=cmd_subscribe)

    serve_cmd = commands.add_parser("serve", help="Receive webhooks and decide in the terminal or Slack")
    serve_cmd.add_argument("--host", default="127.0.0.1")
    serve_cmd.add_argument("--port", type=int, default=8787)
    serve_cmd.add_argument("--slack", action="store_true", help="Post approval cards to Slack instead of prompting")
    serve_cmd.set_defaults(handler=serve)

    watch = commands.add_parser("watch", help="Poll for held calls and decide in the terminal")
    watch.add_argument("--interval", type=int, default=5)
    watch.set_defaults(handler=cmd_watch)

    commands.add_parser("list", help="Show pending actions").set_defaults(handler=cmd_list)
    for name in ("review", "approve", "decline"):
        decide = commands.add_parser(name, help=f"{name.title()} one pending action")
        decide.add_argument("action_id")
        decide.set_defaults(handler=cmd_decide)

    args = parser.parse_args()
    config = Config(args)
    try:
        args.handler(config, args)
    except (ApiError, URLError) as exc:
        sys.exit(f"Request failed: {exc}")


if __name__ == "__main__":
    main()
