"""Signalmark — a quiet tracker for sent Gmail messages.

Flask backend that serves the vanilla JS dashboard and implements the
tracking API contract used by the Gmail extension.
"""
from __future__ import annotations

import base64
import json
import logging
import os
import random
import re
import sqlite3
import string
import threading
import time
from datetime import datetime, timedelta, timezone

import gmail_client
from flask import Flask, Response, g, jsonify, redirect, render_template, request

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "signalmark.db")

TRANSPARENT_GIF = base64.b64decode(
    "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
)


def _broadcast_track_event(event_type: str, track_row: dict) -> None:
    """Push a track event to all connected WebSocket clients."""
    broadcast.broadcast({
        "type": event_type,
        "track_id": track_row.get("track_id"),
        "track": track_row,
    })

EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

SCHEMA = """
CREATE TABLE IF NOT EXISTS tracks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    track_id TEXT UNIQUE NOT NULL,
    user_email TEXT NOT NULL,
    recipient TEXT NOT NULL,
    subject TEXT,
    status TEXT NOT NULL DEFAULT 'sent' CHECK (status IN ('sent', 'opened')),
    sent_at TEXT NOT NULL,
    opened_at TEXT,
    created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tracks_user_email ON tracks(user_email);
CREATE INDEX IF NOT EXISTS idx_tracks_track_id ON tracks(track_id);
"""

KV_SCHEMA = """
CREATE TABLE IF NOT EXISTS app_settings (
    key TEXT PRIMARY KEY,
    value TEXT
);
"""

GMAIL_SYNC_INTERVAL = int(os.environ.get("GMAIL_SYNC_INTERVAL", "60"))
GMAIL_LOOKBACK_DAYS = int(os.environ.get("GMAIL_LOOKBACK_DAYS", "30"))

EXTRA_COLUMNS = (
    ("gmail_id", "TEXT"),
    ("source", "TEXT NOT NULL DEFAULT 'manual'"),
    ("thread_id", "TEXT"),
)

app = Flask(
    __name__,
    template_folder=os.path.join(BASE_DIR, "templates"),
    static_folder=os.path.join(BASE_DIR, "static"),
)


# ─── SSE (Server-Sent Events) broadcast ──────────────────────────────────
import queue as _queue

class SSEBroadcast:
    """Simple SSE broadcast: each listener gets its own queue."""

    def __init__(self) -> None:
        self._clients: set[_queue.Queue] = set()
        self._lock = threading.Lock()

    def subscribe(self) -> _queue.Queue:
        q: _queue.Queue = _queue.Queue()
        with self._lock:
            self._clients.add(q)
        return q

    def unsubscribe(self, q: _queue.Queue) -> None:
        with self._lock:
            self._clients.discard(q)

    def broadcast(self, message: dict) -> None:
        payload = json.dumps(message)
        with self._lock:
            clients = list(self._clients)
        for q in clients:
            try:
                q.put_nowait(payload)
            except _queue.Full:
                pass


broadcast = SSEBroadcast()


@app.get("/api/events")
def sse_stream():
    """SSE endpoint — clients connect here for real-time push."""
    q = broadcast.subscribe()

    def generate():
        try:
            yield f"data: {json.dumps({'type': 'connected'})}\n\n"
            while True:
                try:
                    payload = q.get(timeout=30)
                    yield f"data: {payload}\n\n"
                except _queue.Empty:
                    # Send keepalive comment to prevent proxy/browser timeouts
                    yield ": keepalive\n\n"
        except GeneratorExit:
            pass
        finally:
            broadcast.unsubscribe(q)

    response = Response(generate(), mimetype="text/event-stream")
    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    response.headers["Pragma"] = "no-cache"
    response.headers["Expires"] = "0"
    response.headers["X-Accel-Buffering"] = "no"
    response.headers["Access-Control-Allow-Origin"] = "*"
    response.headers["Connection"] = "keep-alive"
    response.headers["Content-Type"] = "text/event-stream; charset=utf-8"
    response.direct_passthrough = True
    return response


def _detect_tunnel_url() -> str | None:
    """Read cloudflared tunnel log to extract the public trycloudflare.com URL.
    Returns the most recent tunnel URL found, preferring active (non-shutdown) tunnels.
    """
    tunnel_log = os.path.join(BASE_DIR, "tunnel.log")
    if not os.path.exists(tunnel_log):
        return None
    try:
        import re as _re
        urls = []  # list of (line_number, url)
        shutdown_found = False
        with open(tunnel_log, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                if "trycloudflare.com" in line:
                    m = _re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", line)
                    if m:
                        urls.append(m.group(0))
                if "shutdown" in line.lower() or "exiting" in line.lower():
                    shutdown_found = True
        if not urls:
            return None
        # If no shutdown found, the last tunnel URL is likely active
        if not shutdown_found:
            return urls[-1]
        # If shutdown found, return the URL before the shutdown
        return urls[-1] if urls else None
    except Exception:
        pass
    return None


def get_pixel_base_url() -> str:
    """Return the public URL used for tracking pixels.

    Priority:
    1. PIXEL_BASE_URL env var (explicit override)
    2. Auto-detected cloudflared tunnel URL
    3. Auto-detected LAN IP
    4. Flask request.url_root (localhost fallback)
    """
    env_url = os.environ.get("PIXEL_BASE_URL")
    if env_url:
        return env_url.rstrip("/")
    tunnel = _detect_tunnel_url()
    if tunnel:
        return tunnel
    ip = _detect_lan_ip()
    if ip:
        return f"http://{ip}:{os.environ.get('PORT', '5055')}"
    return None  # will fall back to request.url_root at call site


def _detect_lan_ip() -> str | None:
    """Detect the machine's LAN IP address."""
    try:
        import socket
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.settimeout(0.1)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        pass
    return None


def utcnow() -> str:
    return datetime.now(timezone.utc).isoformat()


def parse_iso(value) -> str | None:
    if value is None or str(value).strip() == "":
        return None
    try:
        dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
    except ValueError:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc).isoformat()


def get_db() -> sqlite3.Connection:
    db = getattr(g, "_db", None)
    if db is None:
        db = sqlite3.connect(DB_PATH)
        db.row_factory = sqlite3.Row
        db.execute("PRAGMA journal_mode=WAL")
        db.execute("PRAGMA busy_timeout=5000")
        g._db = db
    return db


@app.teardown_appcontext
def close_db(_exc) -> None:
    db = getattr(g, "_db", None)
    if db is not None:
        db.close()


def seed_demo(db: sqlite3.Connection) -> None:
    now = datetime.now(timezone.utc)
    rows = [
        (
            "SM-welcome-2026",
            "demo@example.com",
            "maya@northstar.studio",
            "Quick follow-up from our conversation",
            "opened",
            now - timedelta(minutes=42),
            now - timedelta(minutes=18),
        ),
        (
            "SM-brief-2026",
            "demo@example.com",
            "jordan@fieldnotes.co",
            "The brief for next week",
            "sent",
            now - timedelta(hours=4),
            None,
        ),
        (
            "SM-intro-2026",
            "demo@example.com",
            "alex@harborcollective.com",
            "Introduction — Alex, meet the team",
            "opened",
            now - timedelta(hours=26),
            now - timedelta(hours=23),
        ),
    ]
    db.executemany(
        """INSERT INTO tracks
           (track_id, user_email, recipient, subject, status, sent_at, opened_at, created_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
        [
            (
                track_id,
                user_email,
                recipient,
                subject,
                status,
                sent_at.isoformat(),
                opened_at.isoformat() if opened_at else None,
                sent_at.isoformat(),
            )
            for track_id, user_email, recipient, subject, status, sent_at, opened_at in rows
        ],
    )
    db.commit()


def init_db() -> None:
    with sqlite3.connect(DB_PATH) as db:
        db.executescript(SCHEMA)
        # Demo data removed - start with empty database


def serialize(row: sqlite3.Row) -> dict:
    return {
        "id": row["id"],
        "track_id": row["track_id"],
        "user_email": row["user_email"],
        "recipient": row["recipient"],
        "subject": row["subject"],
        "status": row["status"],
        "sent_at": row["sent_at"],
        "opened_at": row["opened_at"],
        "created_at": row["created_at"],
        "source": row["source"] if "source" in row.keys() else "manual",
        "gmail_id": row["gmail_id"] if "gmail_id" in row.keys() else None,
    }


def migrate_db() -> None:
    with sqlite3.connect(DB_PATH) as db:
        db.executescript(KV_SCHEMA)
        existing = {row[1] for row in db.execute("PRAGMA table_info(tracks)")}
        for name, decl in EXTRA_COLUMNS:
            if name not in existing:
                db.execute(f"ALTER TABLE tracks ADD COLUMN {name} {decl}")
        db.execute("CREATE INDEX IF NOT EXISTS idx_tracks_gmail_id ON tracks(gmail_id)")


def get_setting(key: str, default=None) -> str | None:
    row = get_db().execute(
        "SELECT value FROM app_settings WHERE key = ?", (key,)
    ).fetchone()
    return row["value"] if row else default


def set_setting(key: str, value: str) -> None:
    get_db().execute(
        "INSERT INTO app_settings (key, value) VALUES (?, ?) "
        "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
        (key, value),
    )
    get_db().commit()


def perform_sync() -> dict:
    service = gmail_client.get_service()
    if service is None:
        raise RuntimeError("Gmail is not connected.")

    profile = gmail_client.get_profile(service)
    history_id = profile.get("historyId")
    last_history_id = get_setting("gmail_last_history_id")

    if last_history_id and history_id and int(history_id) > int(last_history_id):
        added = _process_history(service, last_history_id)
    else:
        added = 0

    set_setting("gmail_last_history_id", str(history_id) if history_id else "")
    set_setting("gmail_last_sync_epoch", str(time.time()))
    return {"added": added, "scanned": added}


def _process_history(service, start_history_id: str) -> int:
    added = 0
    seen = set()
    for record in gmail_client.fetch_history(service, start_history_id):
        for change in record.get("messageAdded", []):
            msg = change.get("message", {})
            gmail_id = msg.get("id")
            if not gmail_id or gmail_id in seen:
                continue
            seen.add(gmail_id)
            if _create_track_from_message(service, gmail_id):
                added += 1
    return added


def _create_track_from_message(service, gmail_id: str) -> bool:
    try:
        meta = gmail_client.get_message_metadata(service, gmail_id)
    except Exception:
        return False

    if not meta.get("recipients"):
        return False

    existing = get_db().execute(
        "SELECT id FROM tracks WHERE gmail_id = ?", (gmail_id,)
    ).fetchone()
    if existing:
        return False

    user_email = get_setting("gmail_account") or ""
    track_id = f"gm_{gmail_id}"
    try:
        get_db().execute(
            """INSERT INTO tracks
               (track_id, user_email, recipient, subject, status,
                sent_at, opened_at, created_at, source, gmail_id, thread_id)
               VALUES (?, ?, ?, ?, 'delivered', ?, NULL, ?, 'history', ?, ?)""",
            (
                track_id,
                user_email,
                meta["recipients"][0],
                meta.get("subject") or None,
                meta.get("sent_at").isoformat() if meta.get("sent_at") else utcnow(),
                utcnow(),
                gmail_id,
                meta.get("thread_id"),
            ),
        )
        get_db().commit()
    except sqlite3.IntegrityError:
        return False

    broadcast.broadcast({
        "type": "track_updated",
        "track_id": track_id,
        "gmail_id": gmail_id,
        "status": "delivered",
        "recipient": meta["recipients"][0],
        "subject": meta.get("subject", ""),
    })
    return True


_sync_lock = threading.Lock()
_sync_thread_started = False


def _sync_loop() -> None:
    while True:
        time.sleep(GMAIL_SYNC_INTERVAL)
        with _sync_lock:
            with app.app_context():
                try:
                    if get_setting("gmail_auto_sync", "1") != "1":
                        continue
                    perform_sync()
                except Exception:
                    continue


def start_background_sync() -> None:
    global _sync_thread_started
    if _sync_thread_started:
        return
    _sync_thread_started = True
    threading.Thread(target=_sync_loop, daemon=True, name="gmail-sync").start()


def json_error(message: str, status: int):
    return jsonify({"error": message}), status


def require_email(value: str) -> str | None:
    value = (value or "").strip()
    if not EMAIL_RE.match(value):
        return None
    return value


def new_track_id() -> str:
    alphabet = string.ascii_lowercase + string.digits
    return "sm_" + "".join(random.choices(alphabet, k=12))


@app.get("/")
def index():
    return render_template("index.html")


@app.get("/api/health")
def health():
    return jsonify({"status": "ok", "service": "signalmark"})


@app.get("/api/tracks")
def list_tracks():
    user_email = require_email(request.args.get("user_email", ""))
    if user_email is None:
        return json_error("A valid user_email query parameter is required.", 400)
    rows = get_db().execute(
        "SELECT * FROM tracks WHERE user_email = ? ORDER BY sent_at DESC",
        (user_email,),
    ).fetchall()
    return jsonify([serialize(row) for row in rows])


@app.post("/api/tracks")
def create_track():
    data = request.get_json(silent=True) or {}

    user_email = require_email(str(data.get("user_email") or ""))
    if user_email is None:
        return json_error("A valid user_email is required.", 400)

    recipient = str(data.get("recipient") or "").strip()
    if not recipient:
        return json_error("Recipient is required.", 400)

    subject = data.get("subject")
    subject = str(subject).strip() if subject is not None else ""

    raw_sent_at = data.get("sent_at")
    sent_at = parse_iso(raw_sent_at)
    if raw_sent_at and sent_at is None:
        return json_error("sent_at must be an ISO 8601 timestamp.", 400)

    provided_track_id = str(data.get("track_id") or "").strip()
    db = get_db()
    attempts = 0
    while True:
        track_id = provided_track_id or new_track_id()
        try:
            db.execute(
                """INSERT INTO tracks
                   (track_id, user_email, recipient, subject, status, sent_at, opened_at, created_at)
                   VALUES (?, ?, ?, ?, 'sent', ?, NULL, ?)""",
                (track_id, user_email, recipient, subject or None, sent_at or utcnow(), utcnow()),
            )
            db.commit()
            break
        except sqlite3.IntegrityError:
            if provided_track_id:
                return json_error("A track with this ID already exists.", 409)
            attempts += 1
            if attempts >= 5:
                raise

    row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()
    serialized = serialize(row)
    _broadcast_track_event("track_created", serialized)
    return jsonify(serialized), 201


@app.get("/api/tracks/<track_id>")
def get_track(track_id: str):
    row = get_db().execute(
        "SELECT * FROM tracks WHERE track_id = ?", (track_id,)
    ).fetchone()
    if row is None:
        return json_error("Track not found", 404)
    return jsonify(serialize(row))


@app.post("/api/tracks/<track_id>/update")
def update_track(track_id: str):
    """Update a track's recipient and subject (used after compose Send)."""
    data = request.get_json(silent=True) or {}
    db = get_db()
    row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()
    if row is None:
        return json_error("Track not found", 404)
    recipient = str(data.get("recipient") or "").strip()
    subject = str(data.get("subject") or "").strip()
    user_email = str(data.get("user_email") or "").strip()
    updates = []
    params = []
    if recipient:
        updates.append("recipient = ?")
        params.append(recipient)
    if subject:
        updates.append("subject = ?")
        params.append(subject)
    if user_email:
        updates.append("user_email = ?")
        params.append(user_email)
    if updates:
        params.append(track_id)
        db.execute(f"UPDATE tracks SET {', '.join(updates)} WHERE track_id = ?", params)
        db.commit()
    row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()
    return jsonify(serialize(row))


@app.get("/api/tracks/<track_id>/open")
def record_open(track_id: str):
    db = get_db()
    row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()

    # Time guard: ignore pixel loads within 2 minutes of track creation.
    # Gmail fetches images when sending to embed/preview, which would
    # falsely mark the track as opened before the recipient sees it.
    if row and row["created_at"]:
        try:
            created = datetime.fromisoformat(row["created_at"])
            if created.tzinfo is None:
                created = created.replace(tzinfo=timezone.utc)
            age = (datetime.now(timezone.utc) - created).total_seconds()
            if age < 120:  # 2 minutes
                response = Response(TRANSPARENT_GIF, mimetype="image/gif")
                response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
                response.headers["Pragma"] = "no-cache"
                response.headers["Access-Control-Allow-Origin"] = "*"
                return response
        except Exception:
            pass

    db.execute(
        """UPDATE tracks
           SET status = 'opened', opened_at = ?
           WHERE track_id = ? AND status = 'sent'""",
        (utcnow(), track_id),
    )
    db.commit()
    # Broadcast the open event so dashboards update in real-time
    row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()
    if row:
        _broadcast_track_event("track_opened", serialize(row))
    response = Response(TRANSPARENT_GIF, mimetype="image/gif")
    response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
    response.headers["Pragma"] = "no-cache"
    response.headers["Access-Control-Allow-Origin"] = "*"
    return response


@app.post("/api/tracks/<track_id>/simulate-open")
def simulate_open(track_id: str):
    """Manually mark a track as opened (for testing)."""
    db = get_db()
    result = db.execute(
        """UPDATE tracks
           SET status = 'opened', opened_at = ?
           WHERE track_id = ? AND status = 'sent'""",
        (utcnow(), track_id),
    )
    db.commit()
    if result.rowcount == 0:
        row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()
        if row is None:
            return json_error("Track not found", 404)
        # Already opened — still return success
    row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()
    serialized = serialize(row)
    _broadcast_track_event("track_opened", serialized)
    return jsonify(serialized)


@app.get("/api/tracking/summary")
def tracking_summary():
    user_email = require_email(request.args.get("user_email", ""))
    if user_email is None:
        return json_error("A valid user_email query parameter is required.", 400)
    rows = get_db().execute(
        "SELECT * FROM tracks WHERE user_email = ?", (user_email,)
    ).fetchall()
    total = len(rows)
    opened = sum(1 for row in rows if row["status"] == "opened")
    latest_open = max(
        (row["opened_at"] for row in rows if row["opened_at"] is not None),
        default=None,
    )
    return jsonify(
        {
            "total": total,
            "opened": opened,
            "pending": total - opened,
            "open_rate": round((opened / total) * 100) if total else 0,
            "latest_open": latest_open,
        }
    )


@app.get("/api/gmail/status")
def gmail_status():
    last_raw = get_setting("gmail_last_sync_epoch")
    last_sync = (
        datetime.fromtimestamp(float(last_raw), tz=timezone.utc).isoformat()
        if last_raw
        else None
    )
    connected = gmail_client.get_credentials() is not None
    account = get_setting("gmail_account")
    pixel_base = get_pixel_base_url()
    history_id = get_setting("gmail_last_history_id")
    watch_topic = get_setting("gmail_watch_topic") or gmail_client.WATCH_TOPIC
    return jsonify(
        {
            "configured": gmail_client.is_configured(),
            "lib_error": gmail_client.lib_error(),
            "connected": connected,
            "account": account if connected else None,
            "last_sync": last_sync if connected else None,
            "last_history_id": history_id,
            "auto_sync": get_setting("gmail_auto_sync", "1") == "1",
            "lookback_days": GMAIL_LOOKBACK_DAYS,
            "pixel_base_url": pixel_base or request.url_root.rstrip("/"),
            "pixel_reachable": pixel_base is not None and "127.0.0.1" not in pixel_base and "localhost" not in pixel_base,
            "watch_topic": watch_topic if watch_topic else None,
        }
    )


@app.get("/api/gmail/auth-url")
def gmail_auth_url():
    if not gmail_client.is_configured():
        return json_error(
            "Gmail is not configured. Place google_credentials.json in the app folder.",
            400,
        )
    return jsonify({"url": gmail_client.auth_url()})


@app.post("/api/gmail/watch")
def gmail_watch():
    if gmail_client.get_credentials() is None:
        return json_error("Gmail is not connected.", 401)
    topic = gmail_client.WATCH_TOPIC or get_setting("gmail_watch_topic") or ""
    if not topic:
        return json_error("No Pub/Sub topic configured. Set SIGNALMARK_WATCH_TOPIC.", 400)
    try:
        service = gmail_client.get_service()
        result = gmail_client.setup_watch(service, topic)
        set_setting("gmail_watch_topic", topic)
        return jsonify({"ok": True, "watch": result})
    except Exception as exc:
        return json_error(str(exc), 502)


@app.post("/api/gmail/stop-watch")
def gmail_stop_watch():
    if gmail_client.get_credentials() is None:
        return json_error("Gmail is not connected.", 401)
    try:
        service = gmail_client.get_service()
        gmail_client.stop_watch(service)
        return jsonify({"ok": True})
    except Exception as exc:
        return json_error(str(exc), 502)


@app.post("/api/gmail/webhook")
def gmail_webhook():
    data = request.get_json(silent=True) or {}
    message = data.get("message", {})
    if not message:
        return jsonify({"ok": True})
    try:
        decoded = base64.b64decode(message.get("data", "")).decode("utf-8", errors="replace")
        notif = json.loads(decoded)
        if notif.get("emailAddress"):
            set_setting("gmail_account", notif["emailAddress"])
    except Exception:
        pass
    threading.Thread(target=_handle_webhook_sync, daemon=True).start()
    return jsonify({"ok": True})


def _handle_webhook_sync() -> None:
    with app.app_context():
        try:
            perform_sync()
        except Exception:
            pass


@app.post("/api/gmail/inject-pixel")
def inject_pixel():
    if gmail_client.get_credentials() is None:
        return json_error("Gmail is not connected.", 401)
    data = request.get_json(silent=True) or {}
    gmail_id = str(data.get("gmail_id") or "").strip()
    track_id = str(data.get("track_id") or "").strip()
    if not gmail_id or not track_id:
        return json_error("gmail_id and track_id are required.", 400)
    try:
        service = gmail_client.get_service()
        pixel_base = get_pixel_base_url() or request.url_root.rstrip("/")
        pixel_url = f"{pixel_base}/api/tracks/{track_id}/open"
        gmail_client.update_message_with_pixel(service, gmail_id, pixel_url)
        return jsonify({"ok": True})
    except Exception as exc:
        return json_error(str(exc), 502)


log = logging.getLogger(__name__)

if not logging.getLogger().handlers:
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")


@app.get("/oauth/callback")
def oauth_callback():
    error = request.args.get("error")
    code = request.args.get("code")
    if error or not code:
        log.error("OAuth callback error: %s", error or "no_code")
        return redirect(f"/#/settings?gmail=failed&detail={error or 'no_code'}")
    try:
        gmail_client.exchange_code(code)
        service = gmail_client.get_service()
        if service is None:
            log.error("OAuth callback: exchange_code succeeded but get_service() returned None")
            return redirect("/#/settings?gmail=failed&detail=token_invalid")
        email_addr = gmail_client.account_email(service) or ""
        set_setting("gmail_account", email_addr)
        set_setting("gmail_owner", email_addr)
        return redirect("/#/settings?gmail=connected")
    except Exception as exc:
        log.error("OAuth callback failed: %s", exc, exc_info=True)
        return redirect(f"/#/settings?gmail=failed&detail={str(exc)[:120]}")


@app.post("/api/gmail/sync")
def gmail_sync():
    if gmail_client.get_credentials() is None:
        return json_error("Gmail is not connected.", 401)
    try:
        result = perform_sync()
    except Exception as exc:
        return json_error(str(exc), 502)
    return jsonify(result)


@app.post("/api/gmail/disconnect")
def gmail_disconnect():
    gmail_client.disconnect()
    set_setting("gmail_account", "")
    return jsonify({"ok": True})


@app.post("/api/gmail/auto-sync")
def gmail_auto_sync():
    data = request.get_json(silent=True) or {}
    enabled = bool(data.get("enabled", True))
    set_setting("gmail_auto_sync", "1" if enabled else "0")
    return jsonify({"auto_sync": enabled})


@app.post("/api/tracks/prepare")
def prepare_track():
    """Create a track record before send and return the pixel URL.
    The content script injects the pixel into Gmail's compose body,
    then lets Gmail send natively — no interception needed.
    """
    data = request.get_json(silent=True) or {}
    recipient = str(data.get("recipient") or "unknown@pending").strip()
    subject = str(data.get("subject") or "").strip()
    user_email = get_setting("gmail_account") or str(data.get("user_email") or "").strip()

    track_id = new_track_id()
    db = get_db()
    db.execute(
        """INSERT INTO tracks
           (track_id, user_email, recipient, subject, status,
            sent_at, opened_at, created_at, source)
           VALUES (?, ?, ?, ?, 'sent', ?, NULL, ?, 'inject')""",
        (track_id, user_email, recipient or "unknown@pending", subject or None, utcnow(), utcnow()),
    )
    db.commit()

    pixel_base = get_pixel_base_url() or request.url_root.rstrip("/")
    pixel_url = f"{pixel_base}/api/tracks/{track_id}/open"
    row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()
    if row:
        _broadcast_track_event("track_created", serialize(row))
    return jsonify({"track_id": track_id, "pixel_url": pixel_url}), 201


@app.post("/api/send")
def send_tracked_email():
    if gmail_client.get_credentials() is None:
        return json_error("Gmail is not connected.", 401)

    data = request.get_json(silent=True) or {}
    recipient = require_email(str(data.get("recipient") or ""))
    if recipient is None:
        return json_error("A valid recipient email is required.", 400)
    subject = str(data.get("subject") or "").strip()
    text_body = str(data.get("body") or "").strip()
    html_body = str(data.get("html") or "").strip()
    if not text_body and not html_body:
        return json_error("A message body is required.", 400)

    track_id = new_track_id()
    db = get_db()
    db.execute(
        """INSERT INTO tracks
           (track_id, user_email, recipient, subject, status,
            sent_at, opened_at, created_at, source)
           VALUES (?, ?, ?, ?, 'sent', ?, NULL, ?, 'send')""",
        (
            track_id,
            get_setting("gmail_account") or "",
            recipient,
            subject or None,
            utcnow(),
            utcnow(),
        ),
    )
    db.commit()

    if not html_body:
        safe = (
            text_body.replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
        )
        html_body = "<p>" + safe.replace("\n", "<br>") + "</p>"

    pixel_base = get_pixel_base_url() or request.url_root.rstrip('/')
    pixel_url = f"{pixel_base}/api/tracks/{track_id}/open"
    service = gmail_client.get_service()
    sender = get_setting("gmail_account") or ""
    try:
        sent = gmail_client.send_tracked(
            service, sender, recipient, subject, html_body, text_body, pixel_url
        )
    except Exception as exc:
        db.execute("DELETE FROM tracks WHERE track_id = ?", (track_id,))
        db.commit()
        return json_error(f"Gmail send failed: {exc}", 502)

    db.execute(
        "UPDATE tracks SET gmail_id = ?, thread_id = ? WHERE track_id = ?",
        (sent.get("id"), sent.get("threadId"), track_id),
    )
    db.commit()
    row = db.execute("SELECT * FROM tracks WHERE track_id = ?", (track_id,)).fetchone()
    serialized = serialize(row)
    _broadcast_track_event("track_created", serialized)
    return jsonify(serialized), 201


@app.errorhandler(404)
def not_found(_error):
    if request.path.startswith("/api/"):
        return json_error("Not found", 404)
    return render_template("index.html")


@app.errorhandler(500)
def server_error(_error):
    if request.path.startswith("/api/"):
        return json_error("Something went wrong on our side.", 500)
    return json_error("Something went wrong on our side.", 500)


@app.after_request
def add_cors_headers(response: Response) -> Response:
    if request.path.startswith("/api/"):
        response.headers.setdefault("Access-Control-Allow-Origin", "*")
        response.headers.setdefault("Access-Control-Allow-Headers", "Content-Type")
        response.headers.setdefault("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
    return response


init_db()
migrate_db()
start_background_sync()

if __name__ == "__main__":
    app.run(
        host=os.environ.get("HOST", "0.0.0.0"),
        port=int(os.environ.get("PORT", "5055")),
        debug=os.environ.get("FLASK_DEBUG", "").lower() in {"1", "true", "yes"},
        threaded=True,
    )
