#!/usr/bin/env python3
"""
VoicerOnePBX Programmable Voice — Example 3: Spoken Support Search
=================================================================

The caller SPEAKS a question; your backend searches a knowledge base and reads the answer back. This example
is about **speech input** (`gather` with `input:"speech"`) and wiring a gather callback to a real backend
lookup — the front end for a support/FAQ system.

Flow:

    POST /support           (call.inbound.pre_route)   → "ask your question after the tone"  (speech gather)
    POST /support/search    (gather.completed)         transcribe → search KB → read answer → offer next
    POST /support/after     (gather.completed)         1 = ask another · 2 = human agent · else = goodbye

Speech is transcribed **on-device** by the PBX (Apple Speech — audio never leaves the box) and handed to you in
the `speech` field. Your job is just to search on that text.

Run:  VOICER_SECRET=… VOICER_BASE_URL=http://<lan-ip>:8080 python3 app.py
Point the PBX endpoint's control URL at:  http://<lan-ip>:8080/support
"""

from __future__ import annotations

import hashlib
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SECRET = os.environ.get("VOICER_SECRET", "")
BASE   = os.environ.get("VOICER_BASE_URL", "http://localhost:8080")
PORT   = int(os.environ.get("PORT", "8080"))

# A toy knowledge base: each entry has trigger keywords and a spoken answer. Swap this for your real search
# (a DB full-text query, a vector store, an LLM call — the shape is the same: text in, answer out).
KB = [
    (("hours", "open", "close", "time"),
     "We are open Monday to Friday, 9 A M to 6 P M Eastern, and closed on weekends."),
    (("password", "reset", "login", "sign in"),
     "To reset your password, visit acme dot com slash reset and follow the emailed link."),
    (("refund", "return", "money back"),
     "Refunds are processed within 5 business days to your original payment method."),
    (("shipping", "delivery", "track", "order"),
     "Standard shipping takes 3 to 5 business days. You will get a tracking link by email once it ships."),
    (("cancel", "subscription", "unsubscribe"),
     "You can cancel any time from Account, then Billing, then Cancel Subscription."),
]


def search_kb(query: str) -> str | None:
    """Naive keyword search — return the answer whose keywords best overlap the spoken query."""
    q = query.lower()
    best, best_score = None, 0
    for keywords, answer in KB:
        score = sum(1 for k in keywords if k in q)
        if score > best_score:
            best, best_score = answer, score
    return best


# ── The handlers — this is the app ───────────────────────────────────────────

def handle_start(event: dict) -> list:
    """Inbound call: invite a spoken question."""
    return [
        {"say": {"text": "Welcome to Acme support. After the tone, ask your question in a few words. "
                         "For example, what are your hours, or how do I reset my password."}},
        # input:"speech" → the PBX records the caller, transcribes on-device, and returns the text in `speech`.
        # It stops on end-of-speech (silence), the speechTimeout window, or hangup. Use "dtmf speech" to also
        # allow a key-press to barge in (it would come back in `digits`).
        {"gather": {"input": "speech", "speechTimeout": 8, "actionUrl": f"{BASE}/support/search"}},
    ]


def handle_search(event: dict) -> list:
    """The transcript arrived. Search the KB and read back the best answer."""
    if event.get("reason") == "hangup":
        return []
    query = (event.get("speech") or "").strip()

    # Nothing recognised (silence/timeout) — apologise and loop back to the prompt with `redirect`.
    if not query:
        return [
            {"say": {"text": "Sorry, I didn't catch that."}},
            {"redirect": {"url": f"{BASE}/support"}},
        ]

    answer = search_kb(query)
    if answer is None:
        spoken = (f"I heard: {query}. I couldn't find an answer to that. "
                  "Let me connect you with someone who can help.")
        # No match → escalate straight to a human (an internal queue/extension).
        return [{"say": {"text": spoken}}, {"route": {"to": "support"}}]

    print(f"  🔎 query={query!r} → answered")
    return [
        {"say": {"text": f"Here's what I found. {answer}"}},
        {"say": {"text": "To ask another question press 1, to speak to an agent press 2, "
                         "or you can hang up. Thanks for calling."}},
        {"gather": {"numDigits": 1, "timeout": 6, "actionUrl": f"{BASE}/support/after"}},
    ]


def handle_after(event: dict) -> list:
    """After an answer: ask another, escalate, or end."""
    if event.get("reason") == "hangup":
        return []
    digits = event.get("digits", "")
    if digits == "1":
        return [{"redirect": {"url": f"{BASE}/support"}}]                 # loop for another question
    if digits == "2":
        return [{"say": {"text": "Connecting you to an agent."}}, {"route": {"to": "support"}}]
    return [{"say": {"text": "Thanks for calling Acme support. Goodbye."}}, {"hangup": {}}]


ROUTES = {
    "/support":        handle_start,
    "/support/search": handle_search,
    "/support/after":  handle_after,
}


# ── HTTP plumbing (generic; identical across the three examples) ──────────────

def verify(raw_body: bytes, headers) -> bool:
    if not SECRET:
        return True
    ts = headers.get("X-Voicer-Timestamp", "")
    sent = headers.get("X-Voicer-Signature", "").replace("sha256=", "")
    expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sent)


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *_):
        pass

    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        raw = self.rfile.read(length)
        if not verify(raw, self.headers):
            self._send(403, b'{"error":"bad signature"}', "application/json")
            return
        handler = ROUTES.get(self.path.split("?")[0])
        if handler is None:
            self._send(404, b'{"error":"no such route"}', "application/json")
            return
        event = json.loads(raw or b"{}")
        print(f"  → {self.path}  event={event.get('event')} "
              f"speech={event.get('speech','')!r} digits={event.get('digits','')!r}")
        ccd = handler(event)
        self._send(200, json.dumps(ccd).encode(), "application/json")

    def _send(self, code: int, body: bytes, ctype: str):
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


def main():
    print(f"Support-search sample on :{PORT}  (control URL → {BASE}/support)")
    if not SECRET:
        print("  ⚠️  VOICER_SECRET is empty — signature verification is DISABLED (dev only).")
    ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()


if __name__ == "__main__":
    main()
