import hashlib
import json
import logging
import os
import secrets
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any, Dict, Iterator, List, Optional

import requests
from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS
from requests import HTTPError
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
from werkzeug.exceptions import HTTPException

from synthneura.core.disease_taxonomy import load_disease_taxonomy
from synthneura.storage.db import get_engine, resolve_db_url
from synthneura.ui.web.run_pipeline import run_pipeline


def _frontend_dist_path() -> str:
    repo_root = os.path.abspath(
        os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
    )
    return os.path.join(repo_root, "frontend", "dist")


DEFAULT_DB_URL = resolve_db_url()
FRONTEND_DIST = _frontend_dist_path()
OPENAI_URL = "https://api.openai.com/v1/chat/completions"
CHAT_MODEL = os.getenv("SYNTHNEURA_CHAT_MODEL", "gpt-4.1-nano")
CHAT_DAILY_TOKENS = int(os.getenv("SYNTHNEURA_CHAT_DAILY_TOKENS", "10000"))
CHAT_MAX_OUTPUT_TOKENS = int(os.getenv("SYNTHNEURA_CHAT_MAX_OUTPUT_TOKENS", "400"))
LOGGER = logging.getLogger(__name__)

app = Flask(__name__, static_folder=FRONTEND_DIST, static_url_path="")
CORS(app)


@contextmanager
def _connect(db_url: str) -> Iterator[Connection]:
    engine = get_engine(resolve_db_url(db_url))
    with engine.begin() as conn:
        yield conn


def _fetchone(
    conn: Connection, stmt: str, params: Optional[Dict[str, Any]] = None
) -> Any:
    result = conn.execute(text(stmt), params or {})
    return result.mappings().first()


def _fetchall(
    conn: Connection, stmt: str, params: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
    result = conn.execute(text(stmt), params or {})
    return [dict(row) for row in result.mappings().all()]


def _execute(
    conn: Connection, stmt: str, params: Optional[Dict[str, Any]] = None
) -> None:
    conn.execute(text(stmt), params or {})


def _safe_json_loads(value: Any, fallback: Any) -> Any:
    if value is None:
        return fallback
    try:
        return json.loads(value)
    except json.JSONDecodeError:
        return fallback


def _item_name(item: Any) -> str:
    if isinstance(item, dict):
        return str(item.get("normalized_name") or item.get("raw_text") or "")
    if item is None:
        return ""
    return str(item)


def _names_from_items(items: List[Any]) -> List[str]:
    names = [_item_name(item) for item in items]
    return [name for name in names if name]


def _ensure_chat_tables(conn: Connection) -> None:
    _execute(
        conn,
        """
        CREATE TABLE IF NOT EXISTS chat_usage (
            user_id TEXT NOT NULL,
            usage_date TEXT NOT NULL,
            tokens_used INTEGER NOT NULL,
            PRIMARY KEY (user_id, usage_date)
        )
        """,
    )


def _ensure_auth_tables(conn: Connection) -> None:
    _execute(
        conn,
        """
        CREATE TABLE IF NOT EXISTS users (
            user_id TEXT PRIMARY KEY,
            email TEXT NOT NULL UNIQUE,
            password_hash TEXT NOT NULL,
            first_name TEXT,
            last_name TEXT,
            created_at TEXT NOT NULL
        )
        """,
    )
    existing_columns = {col["name"] for col in inspect(conn).get_columns("users")}
    if "first_name" not in existing_columns:
        _execute(conn, "ALTER TABLE users ADD COLUMN first_name TEXT")
    if "last_name" not in existing_columns:
        _execute(conn, "ALTER TABLE users ADD COLUMN last_name TEXT")
    _execute(
        conn,
        """
        CREATE TABLE IF NOT EXISTS user_directory (
            user_id TEXT PRIMARY KEY,
            login_id TEXT NOT NULL UNIQUE,
            first_name TEXT,
            last_name TEXT,
            verification_status TEXT NOT NULL DEFAULT 'pending',
            email_verified INTEGER NOT NULL DEFAULT 0,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            last_login_at TEXT,
            login_count INTEGER NOT NULL DEFAULT 0
        )
        """,
    )


def _upsert_user_directory(
    conn: Connection,
    user_id: str,
    login_id: str,
    first_name: str,
    last_name: str,
) -> None:
    now = datetime.now(timezone.utc).isoformat()
    _execute(
        conn,
        """
        INSERT INTO user_directory (
            user_id,
            login_id,
            first_name,
            last_name,
            verification_status,
            email_verified,
            created_at,
            updated_at
        )
        VALUES (
            :user_id,
            :login_id,
            :first_name,
            :last_name,
            'pending',
            0,
            :created_at,
            :updated_at
        )
        ON CONFLICT (user_id)
        DO UPDATE SET
            login_id = EXCLUDED.login_id,
            first_name = EXCLUDED.first_name,
            last_name = EXCLUDED.last_name,
            updated_at = EXCLUDED.updated_at
        """,
        {
            "user_id": user_id,
            "login_id": login_id,
            "first_name": first_name,
            "last_name": last_name,
            "created_at": now,
            "updated_at": now,
        },
    )


def _ensure_profile_tables(conn: Connection) -> None:
    _execute(
        conn,
        """
        CREATE TABLE IF NOT EXISTS user_preferences (
            user_key TEXT PRIMARY KEY,
            primary_focus TEXT NOT NULL,
            signal_cadence TEXT NOT NULL,
            alert_threshold TEXT NOT NULL,
            updated_at TEXT NOT NULL
        )
        """,
    )
    _execute(
        conn,
        """
        CREATE TABLE IF NOT EXISTS user_saved_signals (
            user_key TEXT NOT NULL,
            label TEXT NOT NULL,
            tracked_count INTEGER NOT NULL DEFAULT 0,
            sort_order INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (user_key, label)
        )
        """,
    )
    _execute(
        conn,
        """
        CREATE TABLE IF NOT EXISTS user_integrations (
            user_key TEXT PRIMARY KEY,
            plan_tier TEXT NOT NULL,
            webhooks_active INTEGER NOT NULL DEFAULT 0,
            slack_enabled INTEGER NOT NULL DEFAULT 0,
            api_key_mask TEXT,
            updated_at TEXT NOT NULL
        )
        """,
    )


def _resolve_user_key(conn: Connection, email: str) -> str:
    row = _fetchone(
        conn,
        "SELECT user_id FROM users WHERE email = :email",
        {"email": email},
    )
    return row["user_id"] if row and row.get("user_id") else email


def _infer_primary_focus(conn: Connection, run_id: Optional[str]) -> str:
    rows = _fetchall(
        conn,
        """
        SELECT disease_category, COUNT(*) AS total
        FROM clinical_trials
        WHERE (:run_id IS NULL OR run_id = :run_id)
          AND disease_category IS NOT NULL
          AND disease_category <> ''
        GROUP BY disease_category
        ORDER BY total DESC, disease_category ASC
        LIMIT 2
        """,
        {"run_id": run_id},
    )
    labels = [row["disease_category"] for row in rows if row.get("disease_category")]
    if labels:
        return " · ".join(labels)
    return "General oncology"


def _infer_signal_cadence(conn: Connection) -> str:
    rows = _fetchall(
        conn,
        """
        SELECT ingested_at
        FROM runs
        ORDER BY ingested_at DESC
        LIMIT 2
        """,
    )
    if len(rows) < 2:
        return "On-demand"
    try:
        latest = datetime.fromisoformat(rows[0]["ingested_at"])
        previous = datetime.fromisoformat(rows[1]["ingested_at"])
    except (TypeError, ValueError):
        return "On-demand"
    diff_hours = abs((latest - previous).total_seconds()) / 3600.0
    if diff_hours <= 8:
        return "Intra-day"
    if diff_hours <= 36:
        return "Daily"
    if diff_hours <= 24 * 8:
        return "Weekly"
    return "Periodic"


def _infer_alert_threshold(conn: Connection, run_id: Optional[str]) -> str:
    if not run_id:
        return "No run data yet"
    total_row = _fetchone(
        conn,
        "SELECT COUNT(*) AS total FROM clinical_trials WHERE run_id = :run_id",
        {"run_id": run_id},
    )
    changed_row = _fetchone(
        conn,
        "SELECT COUNT(*) AS total FROM trial_diffs WHERE run_id = :run_id",
        {"run_id": run_id},
    )
    total = total_row["total"] if total_row else 0
    changed = changed_row["total"] if changed_row else 0
    if total == 0:
        return "No run data yet"
    ratio = changed / total
    if ratio <= 0.05:
        return "High confidence only"
    if ratio <= 0.2:
        return "Balanced sensitivity"
    return "Broad monitoring"


def _seed_profile_defaults(conn: Connection, user_key: str) -> None:
    now = datetime.now(timezone.utc).isoformat()
    latest = _latest_run(conn)
    run_id = latest.get("run_id") if latest else None
    preference = _fetchone(
        conn,
        "SELECT user_key FROM user_preferences WHERE user_key = :user_key",
        {"user_key": user_key},
    )
    if not preference:
        _execute(
            conn,
            """
            INSERT INTO user_preferences (
                user_key, primary_focus, signal_cadence, alert_threshold, updated_at
            )
            VALUES (
                :user_key,
                :primary_focus,
                :signal_cadence,
                :alert_threshold,
                :updated_at
            )
            """,
            {
                "user_key": user_key,
                "primary_focus": _infer_primary_focus(conn, run_id),
                "signal_cadence": _infer_signal_cadence(conn),
                "alert_threshold": _infer_alert_threshold(conn, run_id),
                "updated_at": now,
            },
        )
    signals_total = _fetchone(
        conn,
        "SELECT COUNT(*) AS total FROM user_saved_signals WHERE user_key = :user_key",
        {"user_key": user_key},
    )
    if not signals_total or signals_total["total"] == 0:
        top_extractions = _top_extractions(conn, run_id, 3) if run_id else {}
        biomarker_rows = top_extractions.get("biomarkers", [])
        endpoint_rows = top_extractions.get("endpoints", [])
        seed_rows: List[Dict[str, Any]] = []
        for idx, row in enumerate(biomarker_rows[:3]):
            seed_rows.append(
                {
                    "label": f"{row['value']} shifts",
                    "tracked_count": int(row.get("count", 0)),
                    "sort_order": idx,
                }
            )
        if not seed_rows:
            for idx, row in enumerate(endpoint_rows[:3]):
                seed_rows.append(
                    {
                        "label": f"{row['value']} updates",
                        "tracked_count": int(row.get("count", 0)),
                        "sort_order": idx,
                    }
                )
        if not seed_rows:
            seed_rows = [
                {"label": "Top signals", "tracked_count": 0, "sort_order": 0},
                {"label": "Phase 3 signals", "tracked_count": 0, "sort_order": 1},
                {"label": "Novel biomarkers", "tracked_count": 0, "sort_order": 2},
            ]
        for row in seed_rows:
            _execute(
                conn,
                """
                INSERT INTO user_saved_signals (
                    user_key, label, tracked_count, sort_order
                )
                VALUES (:user_key, :label, :tracked_count, :sort_order)
                ON CONFLICT (user_key, label)
                DO UPDATE SET
                    tracked_count = EXCLUDED.tracked_count,
                    sort_order = EXCLUDED.sort_order
                """,
                {
                    "user_key": user_key,
                    "label": row["label"],
                    "tracked_count": row["tracked_count"],
                    "sort_order": row["sort_order"],
                },
            )
    integration = _fetchone(
        conn,
        "SELECT user_key FROM user_integrations WHERE user_key = :user_key",
        {"user_key": user_key},
    )
    if not integration:
        _execute(
            conn,
            """
            INSERT INTO user_integrations (
                user_key,
                plan_tier,
                webhooks_active,
                slack_enabled,
                api_key_mask,
                updated_at
            )
            VALUES (
                :user_key,
                :plan_tier,
                :webhooks_active,
                :slack_enabled,
                :api_key_mask,
                :updated_at
            )
            """,
            {
                "user_key": user_key,
                "plan_tier": "Builder",
                "webhooks_active": 0,
                "slack_enabled": 0,
                "api_key_mask": "Not configured",
                "updated_at": now,
            },
        )


def _hash_password(password: str) -> str:
    salt = secrets.token_hex(16)
    iterations = 260000
    digest = hashlib.pbkdf2_hmac(
        "sha256", password.encode("utf-8"), bytes.fromhex(salt), iterations
    ).hex()
    return f"pbkdf2_sha256${iterations}${salt}${digest}"


def _verify_password(password: str, stored: str) -> bool:
    try:
        algo, iterations, salt, digest = stored.split("$", 3)
    except ValueError:
        return False
    if algo != "pbkdf2_sha256":
        return False
    derived = hashlib.pbkdf2_hmac(
        "sha256", password.encode("utf-8"), bytes.fromhex(salt), int(iterations)
    ).hex()
    return secrets.compare_digest(derived, digest)


def _estimate_tokens(text: str) -> int:
    return max(1, len(text) // 4)


def _chat_usage(conn: Connection, user_id: str, usage_date: str) -> int:
    row = _fetchone(
        conn,
        """
        SELECT tokens_used
        FROM chat_usage
        WHERE user_id = :user_id AND usage_date = :usage_date
        """,
        {"user_id": user_id, "usage_date": usage_date},
    )
    return row["tokens_used"] if row else 0


def _update_chat_usage(
    conn: Connection, user_id: str, usage_date: str, tokens: int
) -> int:
    current = _chat_usage(conn, user_id, usage_date)
    updated = current + tokens
    _execute(
        conn,
        """
        INSERT INTO chat_usage (user_id, usage_date, tokens_used)
        VALUES (:user_id, :usage_date, :tokens_used)
        ON CONFLICT (user_id, usage_date)
        DO UPDATE SET tokens_used = EXCLUDED.tokens_used
        """,
        {"user_id": user_id, "usage_date": usage_date, "tokens_used": updated},
    )
    return updated


def _retrieve_context(
    conn: Connection, run_id: Optional[str], query: str, limit: int = 8
) -> List[Dict[str, Any]]:
    if not run_id:
        return []
    terms = [term.strip() for term in query.lower().split() if term.strip()]
    like = f"%{terms[0]}%" if terms else "%"
    rows = _fetchall(
        conn,
        """
        SELECT nct_id, title, status, phase, sponsor, conditions, outcomes
        FROM clinical_trials
        WHERE run_id = :run_id
          AND (
            LOWER(title) LIKE :like
            OR LOWER(conditions) LIKE :like
            OR LOWER(outcomes) LIKE :like
            OR LOWER(sponsor) LIKE :like
            OR LOWER(nct_id) LIKE :like
          )
        ORDER BY ingested_at DESC
        LIMIT :limit
        """,
        {"run_id": run_id, "like": like, "limit": limit},
    )

    context: List[Dict[str, Any]] = []
    for row in rows:
        extraction = _fetchone(
            conn,
            """
            SELECT endpoints_json, biomarkers_json
            FROM trial_extractions
            WHERE nct_id = :nct_id AND run_id = :run_id
            """,
            {"nct_id": row["nct_id"], "run_id": run_id},
        )
        endpoints = _names_from_items(
            _safe_json_loads(extraction["endpoints_json"], []) if extraction else []
        )
        biomarkers = _names_from_items(
            _safe_json_loads(extraction["biomarkers_json"], []) if extraction else []
        )
        context.append(
            {
                "nct_id": row["nct_id"],
                "title": row["title"],
                "status": row["status"],
                "phase": row["phase"],
                "sponsor": row["sponsor"],
                "conditions": row["conditions"],
                "outcomes": row["outcomes"],
                "endpoints": endpoints,
                "biomarkers": biomarkers,
            }
        )
    return context


def _latest_runs_summary(conn: Connection, limit: int = 5) -> List[Dict[str, Any]]:
    return _fetchall(
        conn,
        """
        SELECT
            run_id,
            ingested_at,
            query,
            total,
            new_count,
            updated_count,
            unchanged_count
        FROM runs
        ORDER BY ingested_at DESC
        LIMIT :limit
        """,
        {"limit": limit},
    )


def _latest_run_summary(
    conn: Connection, query: Optional[str]
) -> Optional[Dict[str, Any]]:
    if query:
        row = _fetchone(
            conn,
            """
            SELECT
                run_id,
                ingested_at,
                query,
                total,
                new_count,
                updated_count,
                unchanged_count
            FROM runs
            WHERE query = :query
            ORDER BY ingested_at DESC
            LIMIT 1
            """,
            {"query": query},
        )
    else:
        row = _fetchone(
            conn,
            """
            SELECT
                run_id,
                ingested_at,
                query,
                total,
                new_count,
                updated_count,
                unchanged_count
            FROM runs
            ORDER BY ingested_at DESC
            LIMIT 1
            """,
        )
    return dict(row) if row else None


def _latest_run(conn: Connection) -> Dict[str, Any]:
    row = _fetchone(
        conn,
        """
        SELECT run_id, ingested_at, source, query, total, new_count, updated_count,
               unchanged_count, failed_count
        FROM runs
        ORDER BY ingested_at DESC
        LIMIT 1
        """,
    )
    return dict(row) if row else {}


def _top_extractions(conn: Connection, run_id: str, limit: int) -> Dict[str, Any]:
    rows = _fetchall(
        conn,
        """
        SELECT endpoints_json, biomarkers_json
        FROM trial_extractions
        WHERE run_id = :run_id
        """,
        {"run_id": run_id},
    )

    endpoint_counts: Dict[str, int] = {}
    biomarker_counts: Dict[str, int] = {}
    for row in rows:
        endpoints = _safe_json_loads(row["endpoints_json"], [])
        biomarkers = _safe_json_loads(row["biomarkers_json"], [])
        for value in _names_from_items(endpoints):
            endpoint_counts[value] = endpoint_counts.get(value, 0) + 1
        for value in _names_from_items(biomarkers):
            biomarker_counts[value] = biomarker_counts.get(value, 0) + 1

    top_endpoints = sorted(
        endpoint_counts.items(), key=lambda item: item[1], reverse=True
    )
    top_biomarkers = sorted(
        biomarker_counts.items(), key=lambda item: item[1], reverse=True
    )

    return {
        "endpoints": [{"value": v, "count": c} for v, c in top_endpoints[:limit]],
        "biomarkers": [{"value": v, "count": c} for v, c in top_biomarkers[:limit]],
    }


def _phase_weight(phase: Any) -> int:
    if not phase:
        return 0
    value = str(phase).upper()
    if "PHASE4" in value:
        return 4
    if "PHASE3" in value:
        return 3
    if "PHASE2" in value:
        return 2
    if "PHASE1" in value:
        return 1
    return 0


def _status_weight(status: Any) -> int:
    if not status:
        return 0
    value = str(status).lower()
    if "recruit" in value:
        return 2
    if "active" in value:
        return 1
    return 0


def _novel_biomarker(conn: Connection, run_id: str, biomarkers: List[Any]) -> bool:
    biomarker_names = _names_from_items(biomarkers)
    if not biomarker_names:
        return False
    for biomarker in biomarker_names:
        row = _fetchone(
            conn,
            """
            SELECT 1
            FROM trial_extractions
            WHERE biomarkers_json LIKE :biomarker
              AND run_id != :run_id
            LIMIT 1
            """,
            {"biomarker": f"%{biomarker}%", "run_id": run_id},
        )
        if row is None:
            return True
    return False


def _summarize_signal(
    nct_id: str,
    status: Any,
    phase: Any,
    endpoints: List[str],
    biomarkers: List[str],
    changed_fields: List[str],
) -> str:
    endpoint = endpoints[0] if endpoints else "an endpoint"
    biomarker = biomarkers[0] if biomarkers else "a biomarker"
    status_text = status or "status update"
    phase_text = phase or "unphased"
    delta = ", ".join(changed_fields) if changed_fields else "trial fields"
    return (
        f"{nct_id} is a {phase_text} trial with {biomarker}. "
        f"Recent changes in {delta} and {endpoint} may shift signal "
        f"as the status is now {status_text}."
    )


def _summarize_extraction_diff(
    nct_id: str,
    endpoints_added: List[str],
    endpoints_removed: List[str],
    biomarkers_added: List[str],
    biomarkers_removed: List[str],
) -> str:
    endpoint_note = "no endpoint changes"
    biomarker_note = "no biomarker changes"
    if endpoints_added or endpoints_removed:
        endpoint_note = "endpoints changed"
    if biomarkers_added or biomarkers_removed:
        biomarker_note = "biomarkers changed"
    return f"{nct_id} shows {endpoint_note} and {biomarker_note} in this run."


def _fetch_extraction_diffs(
    conn: Connection, run_id: str, limit: int
) -> List[Dict[str, Any]]:
    rows = _fetchall(
        conn,
        """
        SELECT nct_id, endpoints_added, endpoints_removed,
               biomarkers_added, biomarkers_removed, ingested_at, prompt_version
        FROM trial_extraction_diffs
        WHERE run_id = :run_id
        ORDER BY ingested_at DESC
        LIMIT :limit
        """,
        {"run_id": run_id, "limit": limit},
    )
    payload: List[Dict[str, Any]] = []
    for row in rows:
        payload.append(
            {
                "nct_id": row["nct_id"],
                "endpoints_added": _safe_json_loads(row["endpoints_added"], []),
                "endpoints_removed": _safe_json_loads(row["endpoints_removed"], []),
                "biomarkers_added": _safe_json_loads(row["biomarkers_added"], []),
                "biomarkers_removed": _safe_json_loads(row["biomarkers_removed"], []),
                "ingested_at": row["ingested_at"],
                "prompt_version": row["prompt_version"],
            }
        )
    return payload


def _confidence_score(
    endpoints: List[str], biomarkers: List[str], changed_fields: List[str]
) -> float:
    score = 0.0
    if endpoints:
        score += 0.4
    if biomarkers:
        score += 0.4
    if changed_fields:
        score += 0.2
    return round(score, 2)


def _build_narratives(
    run: Dict[str, Any],
    changes_payload: List[Dict[str, Any]],
    novel_biomarkers: List[str],
) -> List[str]:
    narratives: List[str] = []
    new_count = run.get("new_count") or 0
    updated_count = run.get("updated_count") or 0

    if new_count:
        narratives.append(f"{new_count} new trials added in this run.")
    if updated_count:
        narratives.append(f"{updated_count} trials updated since the last run.")

    transition_counts: Dict[str, int] = {}
    for change in changes_payload:
        if "status" not in (change.get("changed_fields") or []):
            continue
        before = (change.get("before") or {}).get("status")
        after = (change.get("after") or {}).get("status")
        if before and after and before != after:
            key = f"{before} → {after}"
            transition_counts[key] = transition_counts.get(key, 0) + 1

    if transition_counts:
        top_transition = max(transition_counts.items(), key=lambda item: item[1])
        narratives.append(f"{top_transition[1]} trials moved from {top_transition[0]}.")

    if novel_biomarkers:
        sample = ", ".join(novel_biomarkers[:2])
        label = f"{len(novel_biomarkers)} new biomarker(s) detected"
        if sample:
            label = f"{label} (e.g., {sample})."
        else:
            label = f"{label}."
        narratives.append(label)

    if not narratives:
        narratives.append("Baseline captured. Deltas appear after the next run.")

    return narratives


@app.get("/api/health")
def health() -> Any:
    return jsonify({"status": "ok"})


@app.post("/api/auth/signup")
def auth_signup() -> Any:
    payload = request.get_json(silent=True) or {}
    email = (payload.get("email") or "").strip().lower()
    password = payload.get("password") or ""
    first_name = (payload.get("first_name") or "").strip()
    last_name = (payload.get("last_name") or "").strip()
    if not email or not password:
        return jsonify({"error": "Email and password are required."}), 400
    db_url = request.args.get("db", DEFAULT_DB_URL)
    with _connect(db_url) as conn:
        _ensure_auth_tables(conn)
        existing = _fetchone(
            conn,
            "SELECT user_id FROM users WHERE email = :email",
            {"email": email},
        )
        if existing:
            return jsonify({"error": "Account already exists."}), 409
        user_id = uuid.uuid4().hex
        password_hash = _hash_password(password)
        _execute(
            conn,
            """
            INSERT INTO users (
                user_id, email, password_hash, first_name, last_name, created_at
            )
            VALUES (
                :user_id, :email, :password_hash, :first_name, :last_name, :created_at
            )
            """,
            {
                "user_id": user_id,
                "email": email,
                "password_hash": password_hash,
                "first_name": first_name,
                "last_name": last_name,
                "created_at": datetime.now(timezone.utc).isoformat(),
            },
        )
        _upsert_user_directory(
            conn,
            user_id=user_id,
            login_id=email,
            first_name=first_name,
            last_name=last_name,
        )
        directory_row = _fetchone(
            conn,
            """
            SELECT verification_status, email_verified
            FROM user_directory
            WHERE user_id = :user_id
            """,
            {"user_id": user_id},
        )
    return jsonify(
        {
            "user": {
                "id": user_id,
                "email": email,
                "first_name": first_name,
                "last_name": last_name,
            },
            "verification": {
                "status": (
                    directory_row["verification_status"] if directory_row else "pending"
                ),
                "email_verified": bool(
                    directory_row["email_verified"] if directory_row else 0
                ),
            },
        }
    )


@app.post("/api/auth/login")
def auth_login() -> Any:
    payload = request.get_json(silent=True) or {}
    email = (payload.get("email") or "").strip().lower()
    password = payload.get("password") or ""
    if not email or not password:
        return jsonify({"error": "Email and password are required."}), 400
    db_url = request.args.get("db", DEFAULT_DB_URL)
    with _connect(db_url) as conn:
        _ensure_auth_tables(conn)
        row = _fetchone(
            conn,
            """
            SELECT user_id, email, password_hash, first_name, last_name
            FROM users
            WHERE email = :email
            """,
            {"email": email},
        )
        if not row or not _verify_password(password, row["password_hash"]):
            return jsonify({"error": "Invalid email or password."}), 401
        _upsert_user_directory(
            conn,
            user_id=row["user_id"],
            login_id=row["email"],
            first_name=row.get("first_name") or "",
            last_name=row.get("last_name") or "",
        )
        now = datetime.now(timezone.utc).isoformat()
        _execute(
            conn,
            """
            UPDATE user_directory
            SET last_login_at = :now,
                login_count = login_count + 1,
                updated_at = :now
            WHERE user_id = :user_id
            """,
            {"now": now, "user_id": row["user_id"]},
        )
        directory_row = _fetchone(
            conn,
            """
            SELECT verification_status, email_verified
            FROM user_directory
            WHERE user_id = :user_id
            """,
            {"user_id": row["user_id"]},
        )
    return jsonify(
        {
            "user": {
                "id": row["user_id"],
                "email": row["email"],
                "first_name": row.get("first_name"),
                "last_name": row.get("last_name"),
            },
            "verification": {
                "status": (
                    directory_row["verification_status"] if directory_row else "pending"
                ),
                "email_verified": bool(
                    directory_row["email_verified"] if directory_row else 0
                ),
            },
        }
    )


@app.post("/api/auth/logout")
def auth_logout() -> Any:
    return jsonify({"ok": True})


@app.get("/api/auth/profile")
def auth_profile() -> Any:
    email = (request.args.get("email") or "").strip().lower()
    if not email:
        return jsonify({"error": "Email is required."}), 400
    db_url = request.args.get("db", DEFAULT_DB_URL)
    with _connect(db_url) as conn:
        _ensure_auth_tables(conn)
        row = _fetchone(
            conn,
            """
            SELECT user_id, email, first_name, last_name, created_at
            FROM users
            WHERE email = :email
            """,
            {"email": email},
        )
        if not row:
            return jsonify({"error": "User not found."}), 404
    return jsonify({"user": dict(row)})


@app.post("/api/auth/profile")
def auth_profile_update() -> Any:
    payload = request.get_json(silent=True) or {}
    email = (payload.get("email") or "").strip().lower()
    first_name = (payload.get("first_name") or "").strip()
    last_name = (payload.get("last_name") or "").strip()
    if not email:
        return jsonify({"error": "Email is required."}), 400
    db_url = request.args.get("db", DEFAULT_DB_URL)
    with _connect(db_url) as conn:
        _ensure_auth_tables(conn)
        _execute(
            conn,
            """
            UPDATE users
            SET first_name = :first_name, last_name = :last_name
            WHERE email = :email
            """,
            {"first_name": first_name, "last_name": last_name, "email": email},
        )
        row = _fetchone(
            conn,
            """
            SELECT user_id, email, first_name, last_name, created_at
            FROM users
            WHERE email = :email
            """,
            {"email": email},
        )
        if row:
            _upsert_user_directory(
                conn,
                user_id=row["user_id"],
                login_id=row["email"],
                first_name=first_name,
                last_name=last_name,
            )
    return jsonify({"user": dict(row) if row else {"email": email}})


@app.get("/api/auth/verification")
def auth_verification() -> Any:
    email = (request.args.get("email") or "").strip().lower()
    if not email:
        return jsonify({"error": "Email is required."}), 400
    db_url = request.args.get("db", DEFAULT_DB_URL)
    with _connect(db_url) as conn:
        _ensure_auth_tables(conn)
        row = _fetchone(
            conn,
            """
            SELECT u.user_id,
                   u.email,
                   d.verification_status,
                   d.email_verified,
                   d.last_login_at,
                   d.login_count,
                   d.updated_at
            FROM users u
            LEFT JOIN user_directory d ON d.user_id = u.user_id
            WHERE u.email = :email
            """,
            {"email": email},
        )
        if not row:
            return jsonify({"error": "User not found."}), 404
    return jsonify(
        {
            "user_id": row["user_id"],
            "email": row["email"],
            "verification_status": row.get("verification_status") or "pending",
            "email_verified": bool(row.get("email_verified") or 0),
            "last_login_at": row.get("last_login_at"),
            "login_count": row.get("login_count") or 0,
            "updated_at": row.get("updated_at"),
        }
    )


@app.get("/api/profile/summary")
def profile_summary() -> Any:
    email = (request.args.get("email") or "").strip().lower()
    if not email:
        return jsonify({"error": "Email is required."}), 400
    db_url = request.args.get("db", DEFAULT_DB_URL)
    with _connect(db_url) as conn:
        _ensure_auth_tables(conn)
        _ensure_profile_tables(conn)
        user_key = _resolve_user_key(conn, email)
        _seed_profile_defaults(conn, user_key)
        preferences = _fetchone(
            conn,
            """
            SELECT primary_focus, signal_cadence, alert_threshold, updated_at
            FROM user_preferences
            WHERE user_key = :user_key
            """,
            {"user_key": user_key},
        )
        saved_signals = _fetchall(
            conn,
            """
            SELECT label, tracked_count, sort_order
            FROM user_saved_signals
            WHERE user_key = :user_key
            ORDER BY sort_order ASC, tracked_count DESC, label ASC
            LIMIT 6
            """,
            {"user_key": user_key},
        )
        integrations = _fetchone(
            conn,
            """
            SELECT plan_tier, webhooks_active, slack_enabled, api_key_mask, updated_at
            FROM user_integrations
            WHERE user_key = :user_key
            """,
            {"user_key": user_key},
        )
    return jsonify(
        {
            "preferences": dict(preferences) if preferences else {},
            "saved_signals": saved_signals,
            "integrations": dict(integrations) if integrations else {},
        }
    )


@app.get("/api/overview")
def overview() -> Any:
    db_url = request.args.get("db", DEFAULT_DB_URL)
    limit = int(request.args.get("limit", 8))
    with _connect(db_url) as conn:
        run = _latest_run(conn)
        if not run:
            return jsonify({"run": {}, "changes": [], "extractions": {}})
        run_id = request.args.get("run_id") or run.get("run_id")
        if run_id and run_id != run.get("run_id"):
            row = _fetchone(
                conn,
                """
                SELECT
                    run_id,
                    ingested_at,
                    source,
                    query,
                    total,
                    new_count,
                    updated_count,
                    unchanged_count,
                    failed_count
                FROM runs
                WHERE run_id = :run_id
                """,
                {"run_id": run_id},
            )
            if row:
                run = dict(row)
        run_id = run.get("run_id")
        if not run_id:
            return jsonify(
                {
                    "run": run,
                    "changes": [],
                    "extraction_changes": [],
                    "extractions": {"endpoints": [], "biomarkers": []},
                    "novel_biomarkers": [],
                    "baseline": False,
                    "narratives": ["No runs yet."],
                    "signals": [],
                }
            )
        if run_id:
            total_row = _fetchone(
                conn,
                "SELECT COUNT(*) AS total FROM clinical_trials WHERE run_id = :run_id",
                {"run_id": run_id},
            )
            if total_row:
                run["total"] = total_row["total"]
        changes = _fetchall(
            conn,
            """
            SELECT nct_id, changed_fields, before_json, after_json, ingested_at
            FROM trial_diffs
            WHERE run_id = :run_id
            ORDER BY ingested_at DESC
            LIMIT :limit
            """,
            {"run_id": run_id, "limit": limit},
        )
        changes_payload = []
        for row in changes:
            changes_payload.append(
                {
                    "nct_id": row["nct_id"],
                    "changed_fields": _safe_json_loads(row["changed_fields"], []),
                    "before": _safe_json_loads(row["before_json"], {}),
                    "after": _safe_json_loads(row["after_json"], {}),
                    "ingested_at": row["ingested_at"],
                }
            )
        extraction_changes = _fetch_extraction_diffs(conn, run_id, limit)
        extractions = _top_extractions(conn, run_id, limit)
        novel_biomarkers_set = set()
        extraction_rows = _fetchall(
            conn,
            """
            SELECT biomarkers_json
            FROM trial_extractions
            WHERE run_id = :run_id
            """,
            {"run_id": run_id},
        )
        for row in extraction_rows:
            biomarkers = _safe_json_loads(row["biomarkers_json"], [])
            for biomarker in _names_from_items(biomarkers):
                if _novel_biomarker(conn, run_id, [biomarker]):
                    novel_biomarkers_set.add(biomarker)
        novel_biomarkers = sorted(novel_biomarkers_set)

        signals: List[Dict[str, Any]] = []
        for change in changes_payload:
            nct_id = change["nct_id"]
            trial = _fetchone(
                conn,
                """
                SELECT status, phase
                FROM clinical_trials
                WHERE nct_id = :nct_id
                """,
                {"nct_id": nct_id},
            )
            extraction = _fetchone(
                conn,
                """
                SELECT endpoints_json, biomarkers_json
                FROM trial_extractions
                WHERE nct_id = :nct_id AND run_id = :run_id
                """,
                {"nct_id": nct_id, "run_id": run_id},
            )
            endpoints = _names_from_items(
                _safe_json_loads(extraction["endpoints_json"], []) if extraction else []
            )
            biomarkers = _names_from_items(
                _safe_json_loads(extraction["biomarkers_json"], [])
                if extraction
                else []
            )
            novelty = _novel_biomarker(conn, run_id, biomarkers)
            phase = trial["phase"] if trial else None
            status = trial["status"] if trial else None
            score = (
                _phase_weight(phase) * 2
                + _status_weight(status) * 2
                + len(change["changed_fields"]) * 2
                + (3 if novelty else 0)
            )
            signals.append(
                {
                    "nct_id": nct_id,
                    "score": score,
                    "status": status,
                    "phase": phase,
                    "endpoints": endpoints,
                    "biomarkers": biomarkers,
                    "summary": _summarize_signal(
                        nct_id,
                        status,
                        phase,
                        endpoints,
                        biomarkers,
                        change["changed_fields"],
                    ),
                    "changed_fields": change["changed_fields"],
                    "novel_biomarker": novelty,
                    "confidence": _confidence_score(
                        endpoints, biomarkers, change["changed_fields"]
                    ),
                }
            )
        if extraction_changes:
            for change in extraction_changes:
                nct_id = change["nct_id"]
                trial = _fetchone(
                    conn,
                    """
                    SELECT status, phase
                    FROM clinical_trials
                    WHERE nct_id = :nct_id
                    """,
                    {"nct_id": nct_id},
                )
                endpoints_added = change["endpoints_added"]
                endpoints_removed = change["endpoints_removed"]
                biomarkers_added = change["biomarkers_added"]
                biomarkers_removed = change["biomarkers_removed"]
                magnitude = (
                    len(endpoints_added)
                    + len(endpoints_removed)
                    + len(biomarkers_added)
                    + len(biomarkers_removed)
                )
                phase = trial["phase"] if trial else None
                status = trial["status"] if trial else None
                novelty = bool(biomarkers_added)
                score = (
                    _phase_weight(phase) * 2
                    + _status_weight(status)
                    + magnitude * 2
                    + (3 if novelty else 0)
                )
                signals.append(
                    {
                        "nct_id": nct_id,
                        "score": score,
                        "status": status,
                        "phase": phase,
                        "endpoints": endpoints_added or endpoints_removed,
                        "biomarkers": biomarkers_added or biomarkers_removed,
                        "summary": _summarize_extraction_diff(
                            nct_id,
                            endpoints_added,
                            endpoints_removed,
                            biomarkers_added,
                            biomarkers_removed,
                        ),
                        "changed_fields": [],
                        "novel_biomarker": novelty,
                        "confidence": _confidence_score(
                            endpoints_added or [],
                            biomarkers_added or [],
                            [],
                        ),
                        "signal_type": "extraction_diff",
                        "diffs": {
                            "endpoints_added": endpoints_added,
                            "endpoints_removed": endpoints_removed,
                            "biomarkers_added": biomarkers_added,
                            "biomarkers_removed": biomarkers_removed,
                        },
                    }
                )
        signals.sort(key=lambda item: item["score"], reverse=True)
        if not signals:
            new_trials = _fetchall(
                conn,
                """
                SELECT nct_id, status, phase
                FROM clinical_trials
                WHERE run_id = :run_id
                ORDER BY ingested_at DESC
                LIMIT :limit
                """,
                {"run_id": run_id, "limit": limit},
            )
            for row in new_trials:
                nct_id = row["nct_id"]
                extraction = _fetchone(
                    conn,
                    """
                    SELECT endpoints_json, biomarkers_json
                    FROM trial_extractions
                    WHERE nct_id = :nct_id AND run_id = :run_id
                    """,
                    {"nct_id": nct_id, "run_id": run_id},
                )
                endpoints = _names_from_items(
                    _safe_json_loads(extraction["endpoints_json"], [])
                    if extraction
                    else []
                )
                biomarkers = _names_from_items(
                    _safe_json_loads(extraction["biomarkers_json"], [])
                    if extraction
                    else []
                )
                novelty = _novel_biomarker(conn, run_id, biomarkers)
                phase = row["phase"]
                status = row["status"]
                score = (
                    _phase_weight(phase)
                    + _status_weight(status)
                    + (2 if novelty else 0)
                )
                signals.append(
                    {
                        "nct_id": nct_id,
                        "score": score,
                        "status": status,
                        "phase": phase,
                        "endpoints": endpoints,
                        "biomarkers": biomarkers,
                        "summary": _summarize_signal(
                            nct_id, status, phase, endpoints, biomarkers, []
                        ),
                        "changed_fields": [],
                        "novel_biomarker": novelty,
                        "confidence": _confidence_score(endpoints, biomarkers, []),
                        "signal_type": "new",
                    }
                )
            signals.sort(key=lambda item: item["score"], reverse=True)
    return jsonify(
        {
            "run": run,
            "changes": changes_payload,
            "extraction_changes": extraction_changes,
            "extractions": extractions,
            "novel_biomarkers": novel_biomarkers,
            "baseline": bool(
                run.get("new_count")
                and (run.get("updated_count") or 0) == 0
                and len(changes_payload) == 0
            ),
            "narratives": _build_narratives(run, changes_payload, novel_biomarkers),
            "signals": signals[:limit],
        }
    )


@app.get("/api/alerts")
def alerts() -> Any:
    return jsonify(
        {
            "alerts": [
                {
                    "name": "Recruiting status changes",
                    "description": "Notify when recruiting trials change status.",
                },
                {
                    "name": "New biomarker detected",
                    "description": "Flag new biomarkers appearing in the latest run.",
                },
                {
                    "name": "Phase 3 updates",
                    "description": "Track changes in Phase 3 trials only.",
                },
            ]
        }
    )


@app.post("/api/run")
def run_query() -> Any:
    payload = request.get_json(silent=True) or {}
    query = (payload.get("query") or "").strip()
    max_results = int(payload.get("max_results") or 25)
    if not query:
        return jsonify({"status": "error", "detail": "query is required"}), 400
    result = run_pipeline(query=query, max_results=max_results)
    status = 200 if result.get("status") == "ok" else 500
    return jsonify(result), status


@app.get("/api/trials")
def trials() -> Any:
    db_url = request.args.get("db", DEFAULT_DB_URL)
    limit = int(request.args.get("limit", 25))
    page = int(request.args.get("page", 1))
    page_size = int(request.args.get("page_size", limit))
    page = 1 if page < 1 else page
    page_size = 1 if page_size < 1 else page_size
    offset = (page - 1) * page_size
    with _connect(db_url) as conn:
        latest = _latest_run(conn)
        run_id = request.args.get("run_id") or latest.get("run_id") if latest else None
        total_count = 0
        if run_id:
            total_row = _fetchone(
                conn,
                "SELECT COUNT(*) AS total FROM clinical_trials WHERE run_id = :run_id",
                {"run_id": run_id},
            )
            total_count = total_row["total"] if total_row else 0
            rows = _fetchall(
                conn,
                """
                SELECT
                    nct_id,
                    title,
                    status,
                    phase,
                    sponsor,
                    disease_category,
                    conditions,
                    interventions,
                    outcomes,
                    ingested_at
                FROM clinical_trials
                WHERE run_id = :run_id
                ORDER BY ingested_at DESC
                LIMIT :limit OFFSET :offset
                """,
                {"run_id": run_id, "limit": page_size, "offset": offset},
            )
        else:
            total_row = _fetchone(conn, "SELECT COUNT(*) AS total FROM clinical_trials")
            total_count = total_row["total"] if total_row else 0
            rows = _fetchall(
                conn,
                """
                SELECT
                    nct_id,
                    title,
                    status,
                    phase,
                    sponsor,
                    disease_category,
                    conditions,
                    interventions,
                    outcomes,
                    ingested_at
                FROM clinical_trials
                ORDER BY ingested_at DESC
                LIMIT :limit OFFSET :offset
                """,
                {"limit": page_size, "offset": offset},
            )
        extractions_by_id = {}
        if run_id:
            extractions = _fetchall(
                conn,
                """
                SELECT nct_id, endpoints_json, biomarkers_json
                FROM trial_extractions
                WHERE run_id = :run_id
                """,
                {"run_id": run_id},
            )
            for row in extractions:
                extractions_by_id[row["nct_id"]] = {
                    "endpoints": _names_from_items(
                        _safe_json_loads(row["endpoints_json"], [])
                    ),
                    "biomarkers": _names_from_items(
                        _safe_json_loads(row["biomarkers_json"], [])
                    ),
                }
    payload: List[Dict[str, Any]] = []
    for row in rows:
        extraction = extractions_by_id.get(row["nct_id"], {})
        payload.append(
            {
                "nct_id": row["nct_id"],
                "title": row["title"],
                "status": row["status"],
                "phase": row["phase"],
                "sponsor": row["sponsor"],
                "disease_category": row.get("disease_category"),
                "conditions": (
                    (row["conditions"] or "").split(",") if row["conditions"] else []
                ),
                "interventions": (
                    (row["interventions"] or "").split(",")
                    if row["interventions"]
                    else []
                ),
                "outcomes": (
                    (row["outcomes"] or "").split(",") if row["outcomes"] else []
                ),
                "ingested_at": row["ingested_at"],
                "endpoints": extraction.get("endpoints", []),
                "biomarkers": extraction.get("biomarkers", []),
            }
        )
    return jsonify(
        {"trials": payload, "total": total_count, "page": page, "page_size": page_size}
    )


@app.get("/api/filters")
def filters() -> Any:
    taxonomy = load_disease_taxonomy()
    categories = sorted(taxonomy.keys())
    endpoints: List[str] = []
    biomarkers: List[str] = []
    with _connect(DEFAULT_DB_URL) as conn:
        latest = _latest_run(conn)
        run_id = latest.get("run_id") if latest else None
        rows = _fetchall(
            conn,
            """
            SELECT endpoints_json, biomarkers_json
            FROM trial_extractions
            WHERE (:run_id IS NULL OR run_id = :run_id)
            """,
            {"run_id": run_id},
        )
        endpoint_counts: Dict[str, int] = {}
        biomarker_counts: Dict[str, int] = {}
        for row in rows:
            endpoint_items = _safe_json_loads(row["endpoints_json"], [])
            biomarker_items = _safe_json_loads(row["biomarkers_json"], [])
            for name in _names_from_items(endpoint_items):
                endpoint_counts[name] = endpoint_counts.get(name, 0) + 1
            for name in _names_from_items(biomarker_items):
                biomarker_counts[name] = biomarker_counts.get(name, 0) + 1
        endpoints = [
            item[0]
            for item in sorted(
                endpoint_counts.items(),
                key=lambda entry: (-entry[1], entry[0].lower()),
            )
        ][:30]
        biomarkers = [
            item[0]
            for item in sorted(
                biomarker_counts.items(),
                key=lambda entry: (-entry[1], entry[0].lower()),
            )
        ][:30]
    return jsonify(
        {
            "disease_categories": categories,
            "endpoints": endpoints,
            "biomarkers": biomarkers,
        }
    )


@app.post("/api/chat")
def chat() -> Any:
    payload = request.get_json(silent=True) or {}
    message = (payload.get("message") or "").strip()
    user_id = (payload.get("user_id") or "demo").strip()
    if not message:
        return jsonify({"status": "error", "detail": "message is required"}), 400

    api_key = os.getenv("SYNTHNEURA_OPENAI_API_KEY")
    if not api_key:
        return jsonify({"status": "error", "detail": "OpenAI API key missing"}), 400

    usage_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    estimated_input_tokens = _estimate_tokens(message)

    with _connect(DEFAULT_DB_URL) as conn:
        _ensure_chat_tables(conn)
        latest = _latest_run(conn)
        run_id = latest.get("run_id") if latest else None
        lowered = message.lower()
        if (
            "latest run" in lowered
            or "latest runs" in lowered
            or "recent runs" in lowered
        ):
            runs = _latest_runs_summary(conn, limit=5)
            reply_lines = []
            for run in runs:
                reply_lines.append(
                    (
                        f"{run.get('query')} · {run.get('ingested_at')} · "
                        f"total {run.get('total')} (new {run.get('new_count')}, "
                        f"updated {run.get('updated_count')}, "
                        f"unchanged {run.get('unchanged_count')})"
                    )
                )
            reply = "Latest runs:\n" + (
                "\n".join(reply_lines) if reply_lines else "No runs yet."
            )
            return jsonify(
                {
                    "status": "ok",
                    "reply": reply,
                    "tokens_used": _chat_usage(
                        conn,
                        user_id,
                        datetime.now(timezone.utc).strftime("%Y-%m-%d"),
                    ),
                    "tokens_limit": CHAT_DAILY_TOKENS,
                    "model": CHAT_MODEL,
                }
            )
        if "latest" in lowered and "run" in lowered and "melanoma" in lowered:
            summary = _latest_run_summary(conn, "melanoma")
            if summary:
                reply = (
                    "Latest melanoma run: "
                    f"{summary.get('ingested_at')} · total {summary.get('total')} "
                    f"(new {summary.get('new_count')}, "
                    f"updated {summary.get('updated_count')}, "
                    f"unchanged {summary.get('unchanged_count')})."
                )
            else:
                reply = "No melanoma runs found yet."
            return jsonify(
                {
                    "status": "ok",
                    "reply": reply,
                    "tokens_used": _chat_usage(
                        conn,
                        user_id,
                        datetime.now(timezone.utc).strftime("%Y-%m-%d"),
                    ),
                    "tokens_limit": CHAT_DAILY_TOKENS,
                    "model": CHAT_MODEL,
                }
            )
        if (
            "what does that mean" in lowered
            or "what does this mean" in lowered
            or ("what do" in lowered and "mean" in lowered)
        ):
            summary = _latest_run_summary(conn, None)
            if summary:
                new_count = summary.get("new_count") or 0
                updated_count = summary.get("updated_count") or 0
                unchanged_count = summary.get("unchanged_count") or 0
                if updated_count == 0:
                    meaning = (
                        "This run is effectively a baseline snapshot: "
                        "no trials changed since the previous run. "
                        f"It recorded {new_count} new trials and "
                        f"{unchanged_count} unchanged."
                    )
                else:
                    meaning = (
                        "This run includes changes compared to the previous "
                        "snapshot, "
                        f"with {updated_count} updates detected."
                    )
                reply = (
                    f"Latest run summary: {summary.get('query')} · "
                    f"{summary.get('ingested_at')} · total {summary.get('total')}. "
                    f"{meaning}"
                )
            else:
                reply = "There are no runs yet to interpret."
            return jsonify(
                {
                    "status": "ok",
                    "reply": reply,
                    "tokens_used": _chat_usage(
                        conn,
                        user_id,
                        datetime.now(timezone.utc).strftime("%Y-%m-%d"),
                    ),
                    "tokens_limit": CHAT_DAILY_TOKENS,
                    "model": CHAT_MODEL,
                }
            )

        context_rows = _retrieve_context(conn, run_id, message, limit=8)
        context_block = ""
        if context_rows:
            context_lines = []
            for row in context_rows:
                context_lines.append(
                    " | ".join(
                        [
                            row.get("nct_id") or "",
                            row.get("title") or "",
                            row.get("phase") or "",
                            row.get("status") or "",
                            f"biomarkers={','.join(row.get('biomarkers') or [])}",
                            f"endpoints={','.join(row.get('endpoints') or [])}",
                        ]
                    )
                )
            context_block = "\n".join(context_lines)
        used = _chat_usage(conn, user_id, usage_date)
        if used + estimated_input_tokens >= CHAT_DAILY_TOKENS:
            return (
                jsonify(
                    {
                        "status": "error",
                        "detail": "Daily token limit reached.",
                        "tokens_used": used,
                        "tokens_limit": CHAT_DAILY_TOKENS,
                    }
                ),
                429,
            )

        system_prompt = (
            "You are SynthNeura's clinical intelligence assistant. "
            "Answer concisely, cite trial identifiers when relevant, "
            "and do not invent facts. Use only the provided context if available."
        )
        user_prompt = message
        if context_block:
            user_prompt = (
                "Context (latest run trials):\n"
                f"{context_block}\n\n"
                f"User question: {message}"
            )
        headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        }
        body = {
            "model": CHAT_MODEL,
            "temperature": 0.2,
            "max_tokens": CHAT_MAX_OUTPUT_TOKENS,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
        }
        try:
            response = requests.post(OPENAI_URL, headers=headers, json=body, timeout=60)
            response.raise_for_status()
        except HTTPError as exc:
            detail = str(exc)
            return jsonify({"status": "error", "detail": detail}), 502
        except requests.RequestException as exc:
            return jsonify({"status": "error", "detail": str(exc)}), 502

        data = response.json()
        reply = data["choices"][0]["message"]["content"].strip()
        estimated_output_tokens = _estimate_tokens(reply)
        total_tokens = estimated_input_tokens + estimated_output_tokens
        new_total = _update_chat_usage(conn, user_id, usage_date, total_tokens)

    return jsonify(
        {
            "status": "ok",
            "reply": reply,
            "tokens_used": new_total,
            "tokens_limit": CHAT_DAILY_TOKENS,
            "model": CHAT_MODEL,
        }
    )


@app.get("/api/runs")
def runs() -> Any:
    db_url = request.args.get("db", DEFAULT_DB_URL)
    with _connect(db_url) as conn:
        rows = _fetchall(
            conn,
            """
            SELECT run_id, ingested_at, query, source, total
            FROM runs
            ORDER BY ingested_at DESC
            LIMIT 50
            """,
        )
    payload = rows
    return jsonify({"runs": payload})


@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve_frontend(path: str) -> Any:
    if path.startswith("api"):
        return jsonify({"detail": "Not found"}), 404
    if path and os.path.exists(os.path.join(FRONTEND_DIST, path)):
        return send_from_directory(FRONTEND_DIST, path)
    return send_from_directory(FRONTEND_DIST, "index.html")


@app.errorhandler(404)
def spa_fallback(error: Exception) -> Any:
    if request.path.startswith("/api"):
        return jsonify({"detail": "Not found"}), 404
    if os.path.exists(os.path.join(FRONTEND_DIST, "index.html")):
        return send_from_directory(FRONTEND_DIST, "index.html")
    return jsonify({"detail": "Not found"}), 404


@app.errorhandler(Exception)
def api_exception_fallback(error: Exception) -> Any:
    if isinstance(error, HTTPException):
        if request.path.startswith("/api"):
            detail = str(error.description or "Request failed.")
            return jsonify({"error": detail}), int(error.code or 500)
        return error

    LOGGER.exception("Unhandled API error at %s", request.path)
    if request.path.startswith("/api"):
        return jsonify({"error": f"Internal server error: {error}"}), 500
    return jsonify({"detail": "Internal server error"}), 500


if __name__ == "__main__":
    port = int(os.getenv("PORT", os.getenv("SYNTHNEURA_API_PORT", "5001")))
    debug = os.getenv("FLASK_DEBUG", "0") == "1"
    app.run(host="0.0.0.0", port=port, debug=debug)
