#!/usr/bin/env python3
"""freelead — self-hosted lead capture for mikee.pro/free.

Stdlib only. Runs under systemd on 127.0.0.1:8787, proxied by nginx at /free/api/.

POST /lead        form-encoded {email, source, hp, ts} -> JSON {ok:true} or 302 to /free/start/
GET  /leads?token= -> JSON list of leads (admin)
GET  /healthz     -> "ok"

Storage: SQLite at $FREELEAD_DB (default /var/lib/freelead/leads.sqlite3).
Notification: if $FREELEAD_WEBHOOK is set, POSTs JSON to it (ntfy/Slack/anything);
otherwise the lead is logged and retrievable via /leads.

This is the teaching copy: it stores leads and nothing else, which is all the
lessons need. The live funnel at mikee.pro/free runs one extra step on top of
this file — it also forwards each stored lead to a CRM (Mautic) so the list can
be emailed from somewhere other than a SQLite file. Optional, and it comes later:
first get a lead stored, then get it somewhere you can message it from.
"""
import json
import os
import re
import sqlite3
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

DB = os.environ.get("FREELEAD_DB", "/var/lib/freelead/leads.sqlite3")
WEBHOOK = os.environ.get("FREELEAD_WEBHOOK", "")
ADMIN_TOKEN = os.environ.get("FREELEAD_TOKEN", "changeme")
MIN_SECONDS = 2  # form-load -> submit time; faster = bot
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]{2,}$")


def db():
    conn = sqlite3.connect(DB, timeout=5)
    conn.execute(
        """CREATE TABLE IF NOT EXISTS leads (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            ts INTEGER NOT NULL,
            email TEXT NOT NULL,
            source TEXT,
            ip TEXT,
            ua TEXT
        )"""
    )
    return conn


def notify(row):
    if not WEBHOOK:
        return
    try:
        req = urllib.request.Request(
            WEBHOOK,
            data=json.dumps(row).encode(),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        urllib.request.urlopen(req, timeout=5).read()
    except Exception as exc:  # notification must never break capture
        print(f"notify failed: {exc}", flush=True)


class Handler(BaseHTTPRequestHandler):
    server_version = "freelead/1.0"

    def _send(self, code, body, ctype="application/json", headers=None):
        data = body if isinstance(body, bytes) else body.encode()
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        for k, v in (headers or {}).items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        url = urlparse(self.path)
        if url.path == "/healthz":
            return self._send(200, "ok", "text/plain")
        if url.path == "/leads":
            if parse_qs(url.query).get("token", [""])[0] != ADMIN_TOKEN:
                return self._send(403, '{"error":"forbidden"}')
            rows = db().execute(
                "SELECT id, ts, email, source, ip FROM leads ORDER BY id DESC LIMIT 500"
            ).fetchall()
            return self._send(
                200,
                json.dumps(
                    [
                        dict(zip(("id", "ts", "email", "source", "ip"), r))
                        for r in rows
                    ]
                ),
            )
        return self._send(404, '{"error":"not found"}')

    def do_POST(self):
        if urlparse(self.path).path != "/lead":
            return self._send(404, '{"error":"not found"}')
        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length).decode("utf-8", "replace")
        form = {k: v[0] for k, v in parse_qs(raw, keep_blank_values=True).items()}
        wants_json = "fetch" in (self.headers.get("X-Requested-With", "") + self.headers.get("Sec-Fetch-Mode", ""))

        def fail(reason):
            if wants_json:
                return self._send(400, json.dumps({"ok": False, "error": reason}))
            return self._send(302, "", headers={"Location": "/free/start/?e=1"})

        email = form.get("email", "").strip().lower()
        honeypot = form.get("hp", "").strip()
        try:
            elapsed = time.time() - float(form.get("ts", "0"))
        except ValueError:
            elapsed = 0
        if honeypot:
            # bots fill the hidden field: fake success, store nothing
            return self._send(200, '{"ok":true}') if wants_json else self._send(302, "", headers={"Location": "/free/start/"})
        if not EMAIL_RE.match(email) or len(email) > 254:
            return fail("bad email")

        source = form.get("source", "")[:80]
        if elapsed < MIN_SECONDS:
            # fast humans (autofill) are real leads: keep, just flag
            source = (source + "|fast")[:80]

        row = {
            "ts": int(time.time()),
            "email": email,
            "source": source,
            "ip": self.headers.get("X-Real-IP", self.client_address[0]),
            "ua": self.headers.get("User-Agent", "")[:200],
        }
        with db() as conn:
            try:
                conn.execute(
                    "INSERT INTO leads (ts, email, source, ip, ua) VALUES (:ts,:email,:source,:ip,:ua)",
                    row,
                )
            except sqlite3.IntegrityError:
                pass  # duplicate is a success for the user
        notify(row)
        print(f"lead: {email} source={row['source']}", flush=True)

        if wants_json:
            return self._send(200, '{"ok":true}')
        return self._send(302, "", headers={"Location": "/free/start/"})

    def log_message(self, fmt, *args):
        print(f"{self.address_string()} {fmt % args}", flush=True)


if __name__ == "__main__":
    os.makedirs(os.path.dirname(DB), exist_ok=True)
    ThreadingHTTPServer(("127.0.0.1", 8787), Handler).serve_forever()
