"""Leads REST API routes."""

from __future__ import annotations

import json
import logging
import os
import time
from collections import defaultdict
from datetime import date, datetime, timezone
from urllib.parse import urlparse
from threading import Lock

import asyncpg
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse

from peon_mcp.common.auth import AuthInfo, get_auth
from peon_mcp.common.dependencies import get_db, get_project_id
from peon_mcp.common.events import EventType, LeadCapturedEvent, event_bus
from peon_mcp.common.schemas import OkResponse, PaginatedResponse
from peon_mcp.common.scoping import authorize_project_access
from peon_mcp.db import row_to_dict
from peon_mcp.leads.schemas import (
    VALID_LEAD_STATUSES,
    BULK_LEADS_MAX,
    BulkCreateLeadsRequest,
    BulkCreateLeadsResponse,
    BulkLeadError,
    BulkLeadItem,
    CreateLeadRequest,
    LeadResponse,
    LeadStatsResponse,
    PublicLeadCaptureRequest,
    PublicLeadCaptureResponse,
    UpdateLeadRequest,
)

router = APIRouter(tags=["Leads"])
public_router = APIRouter(tags=["Public"])

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Public lead capture — rate limiting (5 requests/IP/hour, in-memory)
# ---------------------------------------------------------------------------

_RATE_LIMIT_MAX = 5
_RATE_LIMIT_WINDOW = 3600  # seconds

# { ip: [timestamp, ...] }
_rate_buckets: dict[str, list[float]] = defaultdict(list)
_rate_lock = Lock()

# Hostnames always allowed (local dev); production origins are looked up via
# the project's capture_hostname setting in the database.
_DEV_HOSTNAMES = {"localhost", "127.0.0.1"}


def _leads_error(status_code: int, error: str, detail: str) -> JSONResponse:
    """Return a consistent error response for leads endpoints."""
    return JSONResponse(status_code=status_code, content={"error": error, "detail": detail})


def _lead_row_to_dict(row) -> dict:
    """Convert a leads DB row to a plain dict, deserializing JSONB metadata."""
    d = row_to_dict(row)
    # asyncpg returns JSONB as a string in some configurations — parse it
    if isinstance(d.get("metadata"), str):
        try:
            d["metadata"] = json.loads(d["metadata"])
        except (ValueError, TypeError):
            d["metadata"] = None
    return d


@router.get(
    "/api/projects/{owner}/{repo}/leads/stats",
    response_model=LeadStatsResponse,
)
async def get_lead_stats(
    request: Request,
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)

    rows = await db.execute_fetchall(
        "SELECT status, COUNT(*) AS cnt FROM leads WHERE project_id = ? GROUP BY status",
        (project_id,),
    )

    by_status: dict[str, int] = {s: 0 for s in VALID_LEAD_STATUSES}
    total = 0
    for row in rows:
        d = row_to_dict(row)
        by_status[d["status"]] = int(d["cnt"])
        total += int(d["cnt"])

    return {"total": total, "by_status": by_status}


@router.get(
    "/api/projects/{owner}/{repo}/leads",
    response_model=PaginatedResponse[LeadResponse],
)
async def list_leads(
    request: Request,
    status: str | None = None,
    source: str | None = None,
    created_after: date | None = None,
    created_before: date | None = None,
    limit: int = Query(default=50, ge=1, le=200),
    offset: int = Query(default=0, ge=0),
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)

    if status is not None and status not in VALID_LEAD_STATUSES:
        return _leads_error(
            400,
            "Bad Request",
            f"Invalid status. Must be one of: {', '.join(VALID_LEAD_STATUSES)}",
        )

    where = "project_id = ?"
    params: list = [project_id]

    if status is not None:
        where += " AND status = ?"
        params.append(status)

    if source is not None:
        where += " AND source = ?"
        params.append(source)

    if created_after is not None:
        where += " AND created_at >= ?"
        params.append(created_after.isoformat())

    if created_before is not None:
        where += " AND created_at < ?"
        params.append(created_before.isoformat())

    count_rows = await db.execute_fetchall(
        f"SELECT COUNT(*) FROM leads WHERE {where}", params
    )
    total = count_rows[0]["count"] if count_rows else 0

    rows = await db.execute_fetchall(
        f"SELECT * FROM leads WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
        params + [limit, offset],
    )
    items = [_lead_row_to_dict(r) for r in rows]

    return {
        "items": items,
        "total": total,
        "limit": limit,
        "offset": offset,
        "has_next": (offset + len(items)) < total,
    }


@router.post(
    "/api/projects/{owner}/{repo}/leads",
    response_model=LeadResponse,
    status_code=201,
)
async def create_lead(
    body: CreateLeadRequest,
    request: Request,
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)

    name = body.name.strip()
    if not name:
        return _leads_error(400, "Bad Request", "name is required")

    if body.status not in VALID_LEAD_STATUSES:
        return _leads_error(
            400,
            "Bad Request",
            f"Invalid status. Must be one of: {', '.join(VALID_LEAD_STATUSES)}",
        )

    metadata_json = json.dumps(body.metadata) if body.metadata is not None else None

    try:
        rows = await db.execute_fetchall(
            """
            INSERT INTO leads (project_id, name, email, contact_phone, company, source, status, notes, metadata)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            RETURNING id
            """,
            (
                project_id,
                name,
                body.email,
                body.contact_phone,
                body.company,
                body.source,
                body.status,
                body.notes,
                metadata_json,
            ),
        )
        await db.commit()
        new_id = rows[0]["id"]
    except asyncpg.UniqueViolationError:
        return _leads_error(
            409,
            "Conflict",
            f"A lead with email '{body.email}' already exists in this project",
        )

    rows = await db.execute_fetchall("SELECT * FROM leads WHERE id = ?", (str(new_id),))
    return _lead_row_to_dict(rows[0])


@router.get(
    "/api/projects/{owner}/{repo}/leads/{lead_id}",
    response_model=LeadResponse,
)
async def get_lead(
    lead_id: str,
    request: Request,
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)

    rows = await db.execute_fetchall(
        "SELECT * FROM leads WHERE id = ? AND project_id = ?",
        (lead_id, project_id),
    )
    if not rows:
        return _leads_error(404, "Not Found", "Lead not found")
    return _lead_row_to_dict(rows[0])


@router.patch(
    "/api/projects/{owner}/{repo}/leads/{lead_id}",
    response_model=LeadResponse,
)
async def update_lead(
    lead_id: str,
    body: UpdateLeadRequest,
    request: Request,
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)

    existing = await db.execute_fetchall(
        "SELECT id FROM leads WHERE id = ? AND project_id = ?",
        (lead_id, project_id),
    )
    if not existing:
        return _leads_error(404, "Not Found", "Lead not found")

    provided = body.model_dump(exclude_unset=True)
    if not provided:
        return _leads_error(400, "Bad Request", "No fields to update")

    if "name" in provided:
        provided["name"] = provided["name"].strip()
        if not provided["name"]:
            return _leads_error(400, "Bad Request", "name cannot be empty")

    if "status" in provided and provided["status"] not in VALID_LEAD_STATUSES:
        return _leads_error(
            400,
            "Bad Request",
            f"Invalid status. Must be one of: {', '.join(VALID_LEAD_STATUSES)}",
        )

    fields: list[str] = []
    params: list = []

    for key in ("name", "email", "contact_phone", "company", "source", "status", "notes"):
        if key in provided:
            fields.append(f"{key} = ?")
            params.append(provided[key])

    if "metadata" in provided:
        fields.append("metadata = ?")
        params.append(
            json.dumps(provided["metadata"]) if provided["metadata"] is not None else None
        )

    fields.append("updated_at = CURRENT_TIMESTAMP")
    params.extend([lead_id, project_id])

    try:
        await db.execute(
            f"UPDATE leads SET {', '.join(fields)} WHERE id = ? AND project_id = ?",
            params,
        )
        await db.commit()
    except asyncpg.UniqueViolationError:
        return _leads_error(
            409,
            "Conflict",
            f"A lead with email '{provided.get('email')}' already exists in this project",
        )

    rows = await db.execute_fetchall(
        "SELECT * FROM leads WHERE id = ? AND project_id = ?",
        (lead_id, project_id),
    )
    return _lead_row_to_dict(rows[0])


@router.post(
    "/api/projects/{owner}/{repo}/leads/bulk",
    response_model=BulkCreateLeadsResponse,
    status_code=200,
)
async def bulk_create_leads(
    body: BulkCreateLeadsRequest,
    request: Request,
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)

    # Validate statuses up-front so we can report per-item errors before touching the DB
    items: list[BulkLeadItem] = []
    valid_items: list[tuple[int, CreateLeadRequest]] = []

    for i, lead in enumerate(body.leads):
        name = lead.name.strip()
        if not name:
            items.append(BulkLeadItem(index=i, status="error", email=lead.email, reason="name is required"))
            continue
        if lead.status not in VALID_LEAD_STATUSES:
            items.append(
                BulkLeadItem(
                    index=i,
                    status="error",
                    email=lead.email,
                    reason=f"Invalid status '{lead.status}'. Must be one of: {', '.join(VALID_LEAD_STATUSES)}",
                )
            )
            continue
        valid_items.append((i, lead))

    if not valid_items:
        items_sorted = sorted(items, key=lambda it: it.index)
        return {"created": 0, "skipped": 0, "errors": len(items_sorted), "items": items_sorted}

    # Pre-fetch existing emails for this project to detect duplicates without
    # relying on exception handling across individual inserts (which would abort
    # the PostgreSQL transaction).
    emails_to_check = [lead.email for _, lead in valid_items]
    placeholders = ", ".join("?" * len(emails_to_check))
    existing_rows = await db.execute_fetchall(
        f"SELECT email FROM leads WHERE project_id = ? AND email IN ({placeholders})",
        [project_id] + emails_to_check,
    )
    existing_emails = {r["email"] for r in existing_rows}

    # Split into insertable vs duplicate
    to_insert: list[tuple[int, CreateLeadRequest]] = []
    for i, lead in valid_items:
        if lead.email in existing_emails:
            items.append(
                BulkLeadItem(
                    index=i,
                    status="skipped",
                    email=lead.email,
                    reason="duplicate",
                )
            )
        else:
            to_insert.append((i, lead))
            # Mark as "seen" so duplicate emails within the batch are also caught
            existing_emails.add(lead.email)

    if to_insert:
        async with db.transaction() as txn:
            for i, lead in to_insert:
                name = lead.name.strip()
                metadata_json = json.dumps(lead.metadata) if lead.metadata is not None else None
                rows = await txn.execute_fetchall(
                    """
                    INSERT INTO leads (project_id, name, email, contact_phone, company, source, status, notes, metadata)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                    RETURNING *
                    """,
                    (
                        project_id,
                        name,
                        lead.email,
                        lead.contact_phone,
                        lead.company,
                        lead.source,
                        lead.status,
                        lead.notes,
                        metadata_json,
                    ),
                )
                lead_data = _lead_row_to_dict(rows[0])
                items.append(BulkLeadItem(index=i, status="created", email=lead.email, lead=LeadResponse(**lead_data)))

    items_sorted = sorted(items, key=lambda it: it.index)
    created_count = sum(1 for it in items_sorted if it.status == "created")
    skipped_count = sum(1 for it in items_sorted if it.status == "skipped")
    error_count = sum(1 for it in items_sorted if it.status == "error")

    return {
        "created": created_count,
        "skipped": skipped_count,
        "errors": error_count,
        "items": items_sorted,
    }


@router.delete(
    "/api/projects/{owner}/{repo}/leads/{lead_id}",
    response_model=OkResponse,
)
async def delete_lead(
    lead_id: str,
    request: Request,
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)

    cursor = await db.execute(
        "DELETE FROM leads WHERE id = ? AND project_id = ?",
        (lead_id, project_id),
    )
    await db.commit()

    if cursor.rowcount == 0:
        return _leads_error(404, "Not Found", "Lead not found")

    return {"ok": True}


# ---------------------------------------------------------------------------
# Public lead capture endpoint
# ---------------------------------------------------------------------------


def _check_rate_limit(ip: str) -> bool:
    """Return True if the IP is within the rate limit, False if exceeded."""
    now = time.time()
    cutoff = now - _RATE_LIMIT_WINDOW
    with _rate_lock:
        timestamps = _rate_buckets[ip]
        # Evict expired entries
        valid = [t for t in timestamps if t > cutoff]
        if len(valid) >= _RATE_LIMIT_MAX:
            _rate_buckets[ip] = valid
            return False
        valid.append(now)
        _rate_buckets[ip] = valid
    return True


def _sanitize(value: str | None) -> str | None:
    """Strip leading/trailing whitespace and truncate to 500 chars."""
    if value is None:
        return None
    return value.strip()[:500]


@public_router.post(
    "/api/public/leads",
    response_model=PublicLeadCaptureResponse,
    status_code=200,
    summary="Public lead capture",
    description=(
        "Unauthenticated endpoint for website lead capture form submissions. "
        "Rate limited to 5 requests per IP per hour."
    ),
)
async def capture_public_lead(
    body: PublicLeadCaptureRequest,
    request: Request,
    db=Depends(get_db),
):
    # --- Honeypot: reject if the hidden 'website' field is filled ---
    if body.website:
        # Silently accept to avoid revealing detection to bots
        return JSONResponse(
            status_code=200,
            content={"ok": True, "message": "Thank you! We'll be in touch soon."},
        )

    # --- Rate limiting ---
    # Prefer the actual connection IP (cannot be spoofed by clients). Only fall
    # back to X-Forwarded-For when the connection itself comes from a trusted
    # reverse-proxy (e.g. 127.0.0.1 / ::1), to avoid client-controlled header
    # abuse that would bypass the rate limiter.
    conn_host = getattr(request.client, "host", "") or ""
    _trusted_proxies = {"127.0.0.1", "::1"}
    if conn_host in _trusted_proxies:
        forwarded = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
        client_ip = forwarded if forwarded else conn_host
    else:
        client_ip = conn_host or "unknown"

    if not _check_rate_limit(client_ip):
        return JSONResponse(
            status_code=429,
            content={"ok": False, "message": "Too many requests. Please try again later."},
        )

    # --- Project routing via origin → capture_hostname mapping ---
    # Extract the hostname from the Origin header (e.g. "https://example.com" → "example.com").
    # Requests without an Origin (server-to-server / curl) fall back to PUBLIC_LEADS_PROJECT_ID.
    origin = request.headers.get("Origin", "")
    project_id: str | None = None

    if origin:
        origin_host = urlparse(origin).hostname or ""
        if origin_host in _DEV_HOSTNAMES:
            # Dev request — fall back to env var
            project_id = os.environ.get("PUBLIC_LEADS_PROJECT_ID", "").strip() or None
        else:
            rows = await db.execute_fetchall(
                "SELECT id FROM projects WHERE capture_hostname = ?",
                (origin_host,),
            )
            if rows:
                project_id = rows[0]["id"]
            else:
                return JSONResponse(
                    status_code=403,
                    content={"ok": False, "message": "Origin not allowed"},
                )
    else:
        # No Origin header — server-to-server call; use env-var fallback
        project_id = os.environ.get("PUBLIC_LEADS_PROJECT_ID", "").strip() or None

    if not project_id:
        logger.error("No project mapped for public lead capture (origin=%r, PUBLIC_LEADS_PROJECT_ID unset)", origin)
        return JSONResponse(
            status_code=503,
            content={"ok": False, "message": "Lead capture is not configured. Please contact support."},
        )

    # --- Sanitize inputs ---
    name = _sanitize(body.name) or ""
    email = (_sanitize(body.email) or "").lower()
    gym = _sanitize(body.gym)
    contact_phone = _sanitize(body.contact_phone)
    notes = _sanitize(body.notes)

    if not name:
        return JSONResponse(
            status_code=400,
            content={"ok": False, "message": "Name is required."},
        )

    # Metadata with abuse-tracking fields
    metadata = {
        "ip": client_ip,
        "captured_at": datetime.now(timezone.utc).isoformat(),
        "user_agent": request.headers.get("User-Agent", "")[:200],
    }

    try:
        await db.execute(
            """
            INSERT INTO leads (project_id, name, email, contact_phone, company, source, status, notes, metadata)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT (project_id, email) DO NOTHING
            """,
            (
                project_id,
                name,
                email,
                contact_phone,
                gym,
                "website_form",
                "new",
                notes,
                json.dumps(metadata),
            ),
        )
        await db.commit()
    except Exception:
        logger.exception("Failed to insert public lead (project=%s, email=%s)", project_id, email)
        return JSONResponse(
            status_code=500,
            content={"ok": False, "message": "Something went wrong. Please try again."},
        )

    # --- Emit event for Discord notification ---
    try:
        event_bus.emit(
            LeadCapturedEvent(
                project_id=project_id,
                lead_name=name,
                lead_email=email,
                lead_company=gym or "",
                source_ip=client_ip,
                lead_data={
                    "name": name,
                    "email": email,
                    "gym": gym,
                },
            )
        )
    except Exception:
        logger.warning("Failed to emit LeadCapturedEvent", exc_info=True)

    logger.info(
        "Public lead captured: project=%s email=%s ip=%s",
        project_id,
        email,
        client_ip,
    )

    return {"ok": True, "message": "Thank you! We'll be in touch soon."}
