#!/usr/bin/env python3
"""
furl.io webhook receiver -> SQLite.

Receives `link.clicked` webhooks from furl.io, verifies their HMAC-SHA256
signature, and stores each click in a local SQLite database. Python 3.8+,
standard library only (no pip install needed).

Usage:
    export FURL_WEBHOOK_SECRET='whsec_...'   # from the link's Webhook panel
    python3 webhook_receiver.py              # listens on 127.0.0.1:8000

Environment variables:
    FURL_WEBHOOK_SECRET  required  the link's signing secret (whsec_...)
    FURL_DB_PATH         optional  SQLite file (default: furl_clicks.db)
    HOST / PORT          optional  bind address (default: 127.0.0.1 / 8000)
    FURL_MAX_SKEW        optional  max timestamp age in seconds (default: 300)

furl.io only delivers to public https:// URLs, so in production run this
behind a TLS-terminating reverse proxy (nginx, Caddy, a tunnel, ...) and use
that https URL as the link's webhook URL.
"""

import hashlib
import hmac
import json
import logging
import os
import sqlite3
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SECRET = os.environ.get("FURL_WEBHOOK_SECRET", "")
DB_PATH = os.environ.get("FURL_DB_PATH", "furl_clicks.db")
HOST = os.environ.get("HOST", "127.0.0.1")
PORT = int(os.environ.get("PORT", "8000"))
MAX_SKEW = int(os.environ.get("FURL_MAX_SKEW", "300"))
MAX_BODY = 64 * 1024

log = logging.getLogger("furl-webhooks")

SCHEMA = """
CREATE TABLE IF NOT EXISTS clicks (
    delivery_id     TEXT PRIMARY KEY,   -- stable across retries: dedup key
    received_at     TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    link_id         INTEGER NOT NULL,
    short_code      TEXT NOT NULL,
    clicked_at      TEXT NOT NULL,
    country         TEXT,
    city            TEXT,
    referrer        TEXT,
    referrer_domain TEXT,
    device_type     TEXT,
    os              TEXT,
    browser         TEXT,
    user_agent      TEXT,
    raw_json        TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clicks_code_time ON clicks (short_code, clicked_at);
"""

_db_lock = threading.Lock()


def open_db(path):
    conn = sqlite3.connect(path, check_same_thread=False)
    conn.execute("PRAGMA journal_mode=WAL")  # readers don't block the writer
    conn.executescript(SCHEMA)
    return conn


def verify_signature(secret, timestamp, body, signature):
    """X-Furl-Signature = "sha256=" + hex(HMAC_SHA256(secret, "<timestamp>." + body))."""
    expected = "sha256=" + hmac.new(
        secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)  # constant-time compare


def save_click(conn, payload, raw):
    link, click = payload["link"], payload["click"]
    with _db_lock, conn:
        cur = conn.execute(
            """INSERT OR IGNORE INTO clicks
               (delivery_id, link_id, short_code, clicked_at, country, city, referrer,
                referrer_domain, device_type, os, browser, user_agent, raw_json)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                payload["id"], link["id"], link["short_code"], click["timestamp"],
                click.get("country"), click.get("city"), click.get("referrer"),
                click.get("referrer_domain"), click.get("device_type"), click.get("os"),
                click.get("browser"), click.get("user_agent"), raw.decode("utf-8"),
            ),
        )
        return cur.rowcount == 1  # False -> duplicate (a retry we already stored)


class Handler(BaseHTTPRequestHandler):
    server_version = "furl-webhook-receiver/1.0"

    def do_POST(self):
        length = int(self.headers.get("Content-Length") or 0)
        if length <= 0 or length > MAX_BODY:
            return self._reply(413 if length > MAX_BODY else 400, "bad body size")
        body = self.rfile.read(length)

        ts = self.headers.get("X-Furl-Timestamp", "")
        sig = self.headers.get("X-Furl-Signature", "")
        if not ts.isdigit() or abs(time.time() - int(ts)) > MAX_SKEW:
            return self._reply(400, "stale or missing timestamp")  # replay protection
        if not verify_signature(SECRET, ts, body, sig):
            return self._reply(401, "invalid signature")

        try:
            payload = json.loads(body)
        except ValueError:
            return self._reply(400, "invalid json")
        if payload.get("event") != "link.clicked":
            return self._reply(204)  # acknowledge event types we don't handle

        try:
            inserted = save_click(self.server.db, payload, body)
        except (KeyError, sqlite3.Error) as exc:
            log.error("store failed: %s", exc)
            return self._reply(500, "store failed")  # 5xx -> furl.io retries

        log.info("%s %s /%s attempt=%s",
                 "stored" if inserted else "duplicate", payload["id"],
                 payload["link"]["short_code"], self.headers.get("X-Furl-Delivery-Attempt"))
        self._reply(204)  # any 2xx marks the delivery successful

    def _reply(self, code, message=""):
        data = message.encode()
        self.send_response(code)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, fmt, *args):  # route http.server logs through logging
        log.debug(fmt, *args)


def main():
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    if not SECRET.startswith("whsec_"):
        sys.exit("Set FURL_WEBHOOK_SECRET to the link's signing secret (whsec_...).")
    server = ThreadingHTTPServer((HOST, PORT), Handler)
    server.db = open_db(DB_PATH)
    log.info("listening on http://%s:%d, saving to %s", HOST, PORT, DB_PATH)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.db.close()


if __name__ == "__main__":
    main()
