"""
backend/api/routes_crm_imports.py
CRM Import Route Family + Client 360 Routes

Implements the canonical CRM API surface as specified in Doc 09 (Root API Spec):
  POST /api/crm/imports          — upload CSV batch
  GET  /api/crm/imports          — list import batches
  GET  /api/crm/imports/{id}     — get batch detail + proposals
  PUT  /api/crm/imports/{id}/approve-proposal — approve a single proposal
  POST /api/crm/imports/{id}/commit — commit approved proposals to canonical
  GET  /api/crm/contacts         — canonical contact list (with QD summary)
  GET  /api/crm/contacts/{id}    — canonical contact detail
  GET  /api/crm/client-360/{id}  — Client 360 aggregated snapshot
  GET  /api/crm/opportunities    — opportunity pipeline list
  PATCH /api/crm/opportunities/{id} — opportunity/deal update
  GET  /api/crm/tasks            — reminder/task list
  PATCH /api/crm/tasks/{id}      — reminder/task lifecycle update
  GET  /api/crm/kanban           — kanban board (canonical leads)
  PATCH /api/crm/leads/{id}/stage — canonical lead stage transition

Uses canonical crm_*, intel_*, workflow_* tables.
"""
from __future__ import annotations

import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status
from pydantic import BaseModel, Field

from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.crm.canonical_schema import ensure_canonical_crm_schema
from backend.services.imports.ingest_service import (
    parse_csv_content,
    infer_column_mapping,
    build_normalized_proposals,
    create_import_batch_record,
    persist_import_batch,
    persist_proposals_as_workflow_actions,
)
from backend.services.client_graph.aggregation_service import (
    get_client_360,
    get_contact_list,
)

logger = logging.getLogger("velocity.api.crm_imports")

# Canonical CRM surfaces are shared with the authenticated WebOS session model.
# Keep the entire router behind Velocity bearer auth so we do not reintroduce
# an unauthenticated read/write path while the canonical tenant model evolves.
router = APIRouter(dependencies=[Depends(get_current_user)])

CANONICAL_LEAD_STAGES = (
    "new",
    "contacted",
    "qualified",
    "site_visit_scheduled",
    "site_visited",
    "negotiation",
    "booking_initiated",
    "booked",
    "lost",
    "dormant",
)
CANONICAL_TASK_STATUSES = ("pending", "confirmed", "done", "snoozed", "cancelled")
CANONICAL_OPPORTUNITY_STAGES = (
    "prospect",
    "qualified",
    "proposal",
    "site_visit",
    "negotiation",
    "booking",
    "agreement",
    "closed_won",
    "closed_lost",
)


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


async def _get_pool(request: Request):
    await ensure_canonical_crm_schema(request.app)
    pool = getattr(request.app.state, "db_pool", None)
    if pool is None:
        raise HTTPException(status_code=503, detail="Database unavailable.")
    return pool


def _tenant_scope(user: UserPrincipal) -> str:
    return user.tenant_id


def _parse_optional_datetime(value: str | None, *, field_name: str) -> datetime | None:
    if value is None:
        return None

    normalized = value.strip()
    if not normalized:
        return None

    try:
        parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00"))
    except ValueError as exc:
        raise HTTPException(status_code=422, detail=f"Invalid ISO-8601 timestamp for '{field_name}'.") from exc

    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _parse_optional_date(value: str | None, *, field_name: str):
    if value is None:
        return None

    normalized = value.strip()
    if not normalized:
        return None

    try:
        return datetime.fromisoformat(normalized.replace("Z", "+00:00")).date()
    except ValueError as exc:
        raise HTTPException(status_code=422, detail=f"Invalid ISO-8601 date for '{field_name}'.") from exc


def _optional_uuid(value: str | None) -> str | None:
    if not value:
        return None
    try:
        return str(uuid.UUID(value))
    except ValueError:
        return None


def _normalize_choice(value: str, *, allowed: tuple[str, ...], field_name: str) -> str:
    normalized = value.strip().lower()
    if normalized not in allowed:
        allowed_values = ", ".join(allowed)
        raise HTTPException(status_code=422, detail=f"Unsupported {field_name}. Expected one of: {allowed_values}.")
    return normalized


def _fields_set(model: BaseModel) -> set[str]:
    fields = getattr(model, "model_fields_set", None)
    if fields is not None:
        return set(fields)
    return set(getattr(model, "__fields_set__", set()))


def _row_value(row, key: str, default: Any = None) -> Any:
    try:
        return row[key]
    except (KeyError, IndexError):
        return default


def _opportunity_payload(row) -> dict[str, Any]:
    value = _row_value(row, "value")
    expected_close_date = _row_value(row, "expected_close_date")
    return {
        "opportunity_id": str(row["opportunity_id"]),
        "stage": row["stage"],
        "value": float(value) if value is not None else None,
        "probability": _row_value(row, "probability"),
        "expected_close_date": expected_close_date.isoformat() if expected_close_date else None,
        "next_action": _row_value(row, "next_action"),
        "notes": _row_value(row, "notes"),
        "person_id": str(row["person_id"]),
        "client_name": row["full_name"],
        "client_phone": row["primary_phone"],
        "project_name": row["project_name"],
    }


# ── Models ────────────────────────────────────────────────────────────────────

class ProposalApprovalRequest(BaseModel):
    proposal_id: str
    decision: str = Field(..., pattern="^(approved|rejected|needs_more_info)$")
    notes: str = Field(default="", max_length=2000)


class CreatePersonRequest(BaseModel):
    full_name: str = Field(..., min_length=1, max_length=200)
    primary_email: str | None = None
    primary_phone: str | None = None
    buyer_type: str | None = None
    budget_band: str | None = None
    project_name: str | None = None
    source_system: str = "manual"
    notes: str | None = None
    metadata_json: dict[str, Any] = Field(default_factory=dict)


class CreateLeadRequest(BaseModel):
    person_id: str
    status: str = "new"
    budget_band: str | None = None
    urgency: str | None = None
    financing_posture: str | None = None
    assigned_user_id: str | None = None
    metadata_json: dict[str, Any] = Field(default_factory=dict)


class CreateReminderRequest(BaseModel):
    person_id: str
    lead_id: str | None = None
    reminder_type: str = "follow_up"
    title: str = Field(..., min_length=1, max_length=500)
    notes: str | None = None
    due_at: str | None = None
    priority: str = "normal"


class ClientDataPatchRequest(BaseModel):
    full_name: str | None = Field(default=None, max_length=256)
    primary_email: str | None = Field(default=None, max_length=256)
    primary_phone: str | None = Field(default=None, max_length=64)
    buyer_type: str | None = Field(default=None, max_length=128)
    communication_preference: str | None = Field(default=None, max_length=64)
    best_contact_time: str | None = Field(default=None, max_length=128)
    lead_status: str | None = Field(default=None, max_length=64)
    budget_band: str | None = Field(default=None, max_length=128)
    urgency: str | None = Field(default=None, max_length=64)


class UpdateReminderRequest(BaseModel):
    status: str = Field(..., min_length=1, max_length=32)
    due_at: str | None = None
    notes: str | None = Field(default=None, max_length=4000)


class UpdateLeadStageRequest(BaseModel):
    status: str = Field(..., min_length=1, max_length=64)
    notes: str | None = Field(default=None, max_length=2000)


class UpdateOpportunityRequest(BaseModel):
    stage: str | None = Field(default=None, max_length=64)
    value: float | None = Field(default=None, ge=0)
    probability: int | None = Field(default=None, ge=0, le=100)
    expected_close_date: str | None = None
    next_action: str | None = Field(default=None, max_length=1000)
    notes: str | None = Field(default=None, max_length=4000)


# ── Import Endpoints ──────────────────────────────────────────────────────────

@router.post("/crm/imports", status_code=201, tags=["CRM Imports"])
async def upload_crm_import(
    request: Request,
    file: UploadFile = File(...),
    source_system: str = Query(default="csv_upload"),
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """
    Upload a CSV file to start a CRM import batch.
    Parses headers, infers column mapping, and creates workflow_actions proposals.
    """
    pool = await _get_pool(request)
    content_bytes = await file.read()
    try:
        content = content_bytes.decode("utf-8")
    except UnicodeDecodeError:
        content = content_bytes.decode("latin-1")

    parsed = parse_csv_content(content)
    mapping = infer_column_mapping(parsed["headers"])

    batch = create_import_batch_record(
        filename=file.filename or "upload.csv",
        row_count=parsed["row_count"],
        mapping_manifest=mapping,
        source_system=source_system,
        tenant_id=_tenant_scope(user),
    )

    proposals = build_normalized_proposals(
        rows=parsed["rows"],
        mapping=mapping["mapped"],
        batch_id=batch["batch_id"],
        source_system=source_system,
    )

    async with pool.acquire() as conn:
        await persist_import_batch(conn, batch)
        inserted = await persist_proposals_as_workflow_actions(conn, proposals, _tenant_scope(user))

    logger.info("Import batch %s: %d rows, %d proposals", batch["batch_id"], parsed["row_count"], inserted)

    return {
        "status": "ok",
        "data": {
            "batch_id": batch["batch_id"],
            "row_count": parsed["row_count"],
            "mapped_columns": mapping["mapped_count"],
            "unmapped_columns": mapping["unmapped_count"],
            "mapping_confidence": mapping["confidence"],
            "proposals_created": inserted,
            "parse_errors": parsed["parse_errors"],
            "lifecycle": "parsed",
            "message": f"Import batch created. {inserted} proposals queued for review.",
        },
    }


@router.get("/crm/imports", tags=["CRM Imports"])
async def list_import_batches(
    request: Request,
    lifecycle: str | None = None,
    limit: int = Query(default=20, ge=1, le=100),
    offset: int = Query(default=0, ge=0),
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """List all CRM import batches with lifecycle status."""
    pool = await _get_pool(request)
    clauses = ["tenant_id = $1"]
    params: list[Any] = [_tenant_scope(user)]

    if lifecycle:
        params.append(lifecycle)
        clauses.append(f"lifecycle = ${len(params)}::import_lifecycle")

    params.extend([limit, offset])
    where = " AND ".join(clauses)

    async with pool.acquire() as conn:
        rows = await conn.fetch(
            f"""
            SELECT batch_id, source_system, uploaded_filename, row_count,
                   mapped_count, unresolved_count, lifecycle, created_at, updated_at
            FROM workflow_import_batches
            WHERE {where}
            ORDER BY created_at DESC
            LIMIT ${len(params) - 1} OFFSET ${len(params)}
            """,
            *params,
        )
        total = await conn.fetchval(
            f"SELECT COUNT(*) FROM workflow_import_batches WHERE {where}",
            *params[:-2],
        )

    batches = [
        {
            "batch_id": str(r["batch_id"]),
            "source_system": r["source_system"],
            "filename": r["uploaded_filename"],
            "row_count": r["row_count"],
            "mapped_count": r["mapped_count"],
            "unresolved_count": r["unresolved_count"],
            "lifecycle": r["lifecycle"],
            "created_at": r["created_at"].isoformat() if r["created_at"] else None,
        }
        for r in rows
    ]

    return {"status": "ok", "data": batches, "meta": {"total": total, "limit": limit, "offset": offset}}


@router.get("/crm/imports/{batch_id}", tags=["CRM Imports"])
async def get_import_batch(
    request: Request,
    batch_id: str,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Get import batch detail including pending proposals."""
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        batch_row = await conn.fetchrow(
            "SELECT * FROM workflow_import_batches WHERE batch_id = $1::uuid AND tenant_id = $2",
            batch_id,
            _tenant_scope(user),
        )
        if not batch_row:
            raise HTTPException(status_code=404, detail=f"Import batch '{batch_id}' not found.")

        proposal_rows = await conn.fetch(
            """
            SELECT action_id, proposal_payload, confidence, status, approval_required, created_at
            FROM workflow_actions
            WHERE tenant_id = $1
              AND action_type = 'import_proposal'
              AND proposal_payload->>'batch_id' = $2
            ORDER BY (proposal_payload->>'row_number')::int ASC
            LIMIT 200
            """,
            _tenant_scope(user),
            batch_id,
        )

    proposals = [
        {
            "proposal_id": str(r["action_id"]),
            "payload": r["proposal_payload"],
            "confidence": float(r["confidence"]) if r["confidence"] else 0.0,
            "status": r["status"],
            "review_required": r["approval_required"],
        }
        for r in proposal_rows
    ]

    return {
        "status": "ok",
        "data": {
            "batch_id": str(batch_row["batch_id"]),
            "source_system": batch_row["source_system"],
            "filename": batch_row["uploaded_filename"],
            "row_count": batch_row["row_count"],
            "mapping_manifest": batch_row["mapping_manifest"],
            "lifecycle": batch_row["lifecycle"],
            "proposals": proposals,
            "proposal_count": len(proposals),
        },
    }


@router.put("/crm/imports/{batch_id}/review-proposal", tags=["CRM Imports"])
async def review_proposal(
    request: Request,
    batch_id: str,
    body: ProposalApprovalRequest,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """
    Human review of a single import proposal.
    Creates a workflow_approvals record and updates the action status.
    Approved actions with high confidence may be auto-staged for commit.
    """
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        action = await conn.fetchrow(
            """
            SELECT action_id, confidence, approval_required
            FROM workflow_actions
            WHERE action_id = $1::uuid
              AND tenant_id = $2
              AND proposal_payload->>'batch_id' = $3
            """,
            body.proposal_id,
            _tenant_scope(user),
            batch_id,
        )
        if not action:
            raise HTTPException(status_code=404, detail="Proposal not found.")

        decision_id = str(uuid.uuid4())
        new_status = "approved" if body.decision == "approved" else "rejected"

        await conn.execute(
            """
            INSERT INTO workflow_approvals (decision_id, tenant_id, action_id, decision, decision_notes, decided_at)
            VALUES ($1::uuid, $2, $3::uuid, $4, $5, NOW())
            """,
            decision_id,
            _tenant_scope(user),
            body.proposal_id,
            body.decision,
            body.notes,
        )
        await conn.execute(
            """
            UPDATE workflow_actions
            SET status = $1::wf_status, updated_at = NOW()
            WHERE action_id = $2::uuid
              AND tenant_id = $3
            """,
            new_status,
            body.proposal_id,
            _tenant_scope(user),
        )

    return {
        "status": "ok",
        "data": {
            "decision_id": decision_id,
            "proposal_id": body.proposal_id,
            "decision": body.decision,
            "message": f"Proposal {body.decision}.",
        },
    }


@router.post("/crm/imports/{batch_id}/commit", tags=["CRM Imports"])
async def commit_approved_proposals(
    request: Request,
    batch_id: str,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """
    Commit all approved proposals for a batch into canonical crm_people + crm_leads tables.
    Only approved proposals are committed. Rejected/pending are skipped.
    This implements the writeback flow from Doc 07 and Doc 09.
    """
    pool = await _get_pool(request)
    committed = 0
    skipped = 0
    errors: list[str] = []

    async with pool.acquire() as conn:
        approved_rows = await conn.fetch(
            """
            SELECT action_id, proposal_payload
            FROM workflow_actions
            WHERE tenant_id = $1
              AND action_type = 'import_proposal'
              AND proposal_payload->>'batch_id' = $2
              AND status = 'approved'
            """,
            _tenant_scope(user),
            batch_id,
        )

        for row in approved_rows:
            try:
                payload = row["proposal_payload"]
                canonical = payload.get("canonical_payload", {})
                if not canonical.get("full_name"):
                    skipped += 1
                    continue

                person_id = str(uuid.uuid4())
                await conn.execute(
                    """
                    INSERT INTO crm_people (
                        person_id, tenant_id, full_name, primary_email, primary_phone,
                        buyer_type, source_confidence, metadata_json, created_at, updated_at
                    ) VALUES (
                        $1::uuid, $2, $3, $4, $5, $6, $7, $8::jsonb, NOW(), NOW()
                    )
                    ON CONFLICT DO NOTHING
                    """,
                    person_id,
                    _tenant_scope(user),
                    canonical.get("full_name"),
                    canonical.get("primary_email"),
                    canonical.get("primary_phone"),
                    canonical.get("buyer_type"),
                    payload.get("confidence", 0.5),
                    json.dumps({"source_batch": batch_id, "import_row": payload.get("row_number")}),
                )

                if canonical.get("status") or canonical.get("budget_band"):
                    lead_id = str(uuid.uuid4())
                    await conn.execute(
                        """
                        INSERT INTO crm_leads (
                            lead_id, tenant_id, person_id, source_system, status, budget_band,
                            metadata_json, created_at, updated_at
                        ) VALUES (
                            $1::uuid, $2, $3::uuid, $4, 'new'::crm_lead_status, $5, $6::jsonb, NOW(), NOW()
                        )
                        ON CONFLICT DO NOTHING
                        """,
                        lead_id,
                        _tenant_scope(user),
                        person_id,
                        payload.get("source_system", "csv_upload"),
                        canonical.get("budget_band"),
                        json.dumps({"import_batch": batch_id}),
                    )

                    # Stage property interest if project_name present
                    if canonical.get("project_name"):
                        await conn.execute(
                            """
                            INSERT INTO crm_property_interests (
                                interest_id, tenant_id, person_id, lead_id, project_name, created_at
                            ) VALUES (
                                $1::uuid, $2, $3::uuid, $4::uuid, $5, NOW()
                            )
                            ON CONFLICT DO NOTHING
                            """,
                            str(uuid.uuid4()),
                            _tenant_scope(user),
                            person_id,
                            lead_id,
                            canonical.get("project_name"),
                        )

                # Mark action as executed
                await conn.execute(
                    """
                    UPDATE workflow_actions
                    SET status = 'executed'::wf_status, updated_at = NOW()
                    WHERE action_id = $1::uuid
                      AND tenant_id = $2
                    """,
                    row["action_id"],
                    _tenant_scope(user),
                )
                committed += 1

            except Exception as e:
                errors.append(f"Proposal {row['action_id']}: {str(e)}")
                skipped += 1

        # Update batch lifecycle
        await conn.execute(
            """
            UPDATE workflow_import_batches
            SET lifecycle = 'committed'::import_lifecycle, canonical_count = $1, updated_at = NOW()
            WHERE batch_id = $2
              AND tenant_id = $3
            """,
            committed,
            batch_id,
            _tenant_scope(user),
        )

    return {
        "status": "ok",
        "data": {
            "batch_id": batch_id,
            "committed": committed,
            "skipped": skipped,
            "errors": errors,
            "lifecycle": "committed",
        },
    }


# ── Contact / Person Endpoints ──────────────────────────────────────────────

@router.get("/crm/contacts", tags=["CRM Contacts"])
async def list_contacts(
    request: Request,
    search: str | None = Query(default=None),
    buyer_type: str | None = Query(default=None),
    status: str | None = Query(default=None),
    limit: int = Query(default=50, ge=1, le=200),
    offset: int = Query(default=0, ge=0),
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Canonical contact list with QD summary, interaction count, and lead status."""
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        result = await get_contact_list(conn, _tenant_scope(user), search, buyer_type, status, limit, offset)
    return {"status": "ok", "data": result}


@router.post("/crm/contacts", status_code=201, tags=["CRM Contacts"])
async def create_contact(
    request: Request,
    body: CreatePersonRequest,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Create a new canonical person record manually."""
    pool = await _get_pool(request)
    person_id = str(uuid.uuid4())

    async with pool.acquire() as conn:
        await conn.execute(
            """
            INSERT INTO crm_people (
                person_id, tenant_id, full_name, primary_email, primary_phone,
                buyer_type, source_confidence, metadata_json, created_at, updated_at
            ) VALUES ($1::uuid, $2, $3, $4, $5, $6, 1.0, $7::jsonb, NOW(), NOW())
            """,
            person_id,
            _tenant_scope(user),
            body.full_name,
            body.primary_email,
            body.primary_phone,
            body.buyer_type,
            json.dumps({**body.metadata_json, "manual_entry": True}),
        )

        if body.project_name or body.budget_band:
            lead_id = str(uuid.uuid4())
            await conn.execute(
                """
                INSERT INTO crm_leads (
                    lead_id, tenant_id, person_id, source_system, status, budget_band,
                    metadata_json, created_at, updated_at
                ) VALUES ($1::uuid, $2, $3::uuid, $4, 'new'::crm_lead_status, $5, '{}'::jsonb, NOW(), NOW())
                """,
                lead_id,
                _tenant_scope(user),
                person_id,
                body.source_system,
                body.budget_band,
            )
            if body.project_name:
                await conn.execute(
                    """
                    INSERT INTO crm_property_interests (interest_id, tenant_id, person_id, lead_id, project_name, created_at)
                    VALUES ($1::uuid, $2, $3::uuid, $4::uuid, $5, NOW())
                    """,
                    str(uuid.uuid4()),
                    _tenant_scope(user),
                    person_id,
                    lead_id,
                    body.project_name,
                )

    return {"status": "ok", "data": {"person_id": person_id, "full_name": body.full_name}}


@router.get("/crm/contacts/{person_id}", tags=["CRM Contacts"])
async def get_contact(
    request: Request,
    person_id: str,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Get a single canonical contact record."""
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """
            SELECT person_id, full_name, primary_email, primary_phone, secondary_phone,
                   buyer_type, persona_labels, source_confidence, created_at, updated_at
            FROM crm_people
            WHERE person_id = $1::uuid
              AND tenant_id = $2
            """,
            person_id,
            _tenant_scope(user),
        )
    if not row:
        raise HTTPException(status_code=404, detail=f"Contact '{person_id}' not found.")
    return {
        "status": "ok",
        "data": {
            "person_id": str(row["person_id"]),
            "full_name": row["full_name"],
            "primary_email": row["primary_email"],
            "primary_phone": row["primary_phone"],
            "buyer_type": row["buyer_type"],
            "persona_labels": row["persona_labels"] or [],
            "source_confidence": float(row["source_confidence"] or 0.0),
        },
    }


# ── Client 360 Endpoint ────────────────────────────────────────────────────

@router.get("/crm/client-360/{person_id}", tags=["CRM Client 360"])
@router.get("/crm/leads/{person_id}/360", tags=["CRM Client 360"])
async def client_360(
    request: Request,
    person_id: str,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """
    Aggregated Client360 dossier — identity, opportunities, interactions,
    property interests, tasks, QD overview, risk flags, and next actions.
    Derived read model — not primary truth.
    """
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        snapshot = await get_client_360(conn, _tenant_scope(user), person_id)
    if not snapshot:
        raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
    return {"status": "ok", "data": snapshot}


@router.get("/crm/leads/{person_id}/properties", tags=["CRM Client 360"])
async def legacy_lead_properties(
    request: Request,
    person_id: str,
    user: UserPrincipal = Depends(get_current_user),
) -> Any:
    """Compatibility read for older WebOS builds that requested lead properties directly."""
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        snapshot = await get_client_360(conn, _tenant_scope(user), person_id)
    if not snapshot:
        raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
    return snapshot.get("property_interests", [])


@router.get("/crm/leads/{person_id}/tasks", tags=["CRM Client 360"])
async def legacy_lead_tasks(
    request: Request,
    person_id: str,
    user: UserPrincipal = Depends(get_current_user),
) -> Any:
    """Compatibility read for older WebOS builds that requested lead tasks directly."""
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        snapshot = await get_client_360(conn, _tenant_scope(user), person_id)
    if not snapshot:
        raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
    return snapshot.get("tasks", [])


# ── Opportunities Endpoint ─────────────────────────────────────────────────

@router.get("/crm/opportunities", tags=["CRM Opportunities"])
async def list_opportunities(
    request: Request,
    stage: str | None = None,
    limit: int = Query(default=50, ge=1, le=200),
    offset: int = Query(default=0, ge=0),
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Canonical opportunity pipeline list."""
    pool = await _get_pool(request)
    clauses = ["co.tenant_id = $1", "cl.tenant_id = $1", "p.tenant_id = $1"]
    params: list[Any] = [_tenant_scope(user)]

    if stage:
        params.append(stage)
        clauses.append(f"co.stage = ${len(params)}::crm_opportunity_stage")

    params.extend([limit, offset])
    where = " AND ".join(clauses)

    async with pool.acquire() as conn:
        rows = await conn.fetch(
            f"""
            SELECT co.opportunity_id, co.stage, co.value, co.probability,
                   co.expected_close_date, co.next_action,
                   p.person_id, p.full_name, p.primary_phone,
                   ip.project_name,
                   co.created_at, co.updated_at
            FROM crm_opportunities co
            INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id
            INNER JOIN crm_people p ON p.person_id = cl.person_id
            LEFT JOIN inventory_projects ip ON ip.project_id = co.project_id
            WHERE {where}
            ORDER BY co.updated_at DESC
            LIMIT ${len(params) - 1} OFFSET ${len(params)}
            """,
            *params,
        )

    opportunities = [
        _opportunity_payload(r)
        for r in rows
    ]

    return {"status": "ok", "data": opportunities, "meta": {"count": len(opportunities)}}


@router.patch("/crm/opportunities/{opportunity_id}", tags=["CRM Opportunities"])
async def update_opportunity(
    request: Request,
    opportunity_id: str,
    body: UpdateOpportunityRequest,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Update canonical opportunity/deal fields without leaving tenant scope."""
    pool = await _get_pool(request)
    updated_fields = _fields_set(body)
    if not updated_fields:
        raise HTTPException(status_code=422, detail="At least one opportunity field must be provided.")

    next_stage = None
    if "stage" in updated_fields:
        if body.stage is None:
            raise HTTPException(status_code=422, detail="Opportunity stage cannot be null.")
        next_stage = _normalize_choice(
            body.stage,
            allowed=CANONICAL_OPPORTUNITY_STAGES,
            field_name="opportunity stage",
        )

    expected_close_date = None
    if "expected_close_date" in updated_fields:
        expected_close_date = _parse_optional_date(body.expected_close_date, field_name="expected_close_date")

    async with pool.acquire() as conn:
        existing = await conn.fetchrow(
            """
            SELECT co.opportunity_id, co.stage, co.value, co.probability,
                   co.expected_close_date, co.next_action, co.notes,
                   p.person_id, p.full_name, p.primary_phone,
                   ip.project_name
            FROM crm_opportunities co
            INNER JOIN crm_leads cl
                ON cl.lead_id = co.lead_id
               AND cl.tenant_id = co.tenant_id
            INNER JOIN crm_people p
                ON p.person_id = cl.person_id
               AND p.tenant_id = co.tenant_id
            LEFT JOIN inventory_projects ip ON ip.project_id = co.project_id
            WHERE co.opportunity_id = $1::uuid
              AND co.tenant_id = $2
            """,
            opportunity_id,
            _tenant_scope(user),
        )
        if not existing:
            raise HTTPException(status_code=404, detail=f"Opportunity '{opportunity_id}' not found.")

        row = await conn.fetchrow(
            """
            WITH updated AS (
                UPDATE crm_opportunities co
                SET stage = COALESCE($3::crm_opportunity_stage, co.stage),
                    value = CASE WHEN $4::boolean THEN $5::numeric ELSE co.value END,
                    probability = CASE WHEN $6::boolean THEN $7::int ELSE co.probability END,
                    expected_close_date = CASE WHEN $8::boolean THEN $9::date ELSE co.expected_close_date END,
                    next_action = CASE WHEN $10::boolean THEN $11 ELSE co.next_action END,
                    notes = CASE WHEN $12::boolean THEN $13 ELSE co.notes END,
                    updated_at = NOW()
                FROM crm_leads cl
                WHERE co.opportunity_id = $1::uuid
                  AND co.tenant_id = $2
                  AND cl.lead_id = co.lead_id
                  AND cl.tenant_id = co.tenant_id
                RETURNING co.opportunity_id, co.lead_id, co.stage, co.value, co.probability,
                          co.expected_close_date, co.next_action, co.notes, co.project_id, co.updated_at
            )
            SELECT updated.opportunity_id, updated.stage, updated.value, updated.probability,
                   updated.expected_close_date, updated.next_action, updated.notes,
                   p.person_id, p.full_name, p.primary_phone,
                   ip.project_name
            FROM updated
            INNER JOIN crm_leads cl
                ON cl.lead_id = updated.lead_id
               AND cl.tenant_id = $2
            INNER JOIN crm_people p
                ON p.person_id = cl.person_id
               AND p.tenant_id = $2
            LEFT JOIN inventory_projects ip ON ip.project_id = updated.project_id
            """,
            opportunity_id,
            _tenant_scope(user),
            next_stage,
            "value" in updated_fields,
            body.value,
            "probability" in updated_fields,
            body.probability,
            "expected_close_date" in updated_fields,
            expected_close_date,
            "next_action" in updated_fields,
            body.next_action,
            "notes" in updated_fields,
            body.notes,
        )

    return {
        "status": "ok",
        "data": _opportunity_payload(row),
        "meta": {
            "previous_stage": existing["stage"],
            "changed": _opportunity_payload(existing) != _opportunity_payload(row),
        },
    }


# ── Tasks / Reminders Endpoint ─────────────────────────────────────────────

@router.get("/crm/tasks", tags=["CRM Tasks"])
async def list_tasks(
    request: Request,
    status_filter: str | None = Query(default="pending", alias="status"),
    assigned_to: str | None = None,
    limit: int = Query(default=50, ge=1, le=200),
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Reminder / task inbox for the CRM operator."""
    pool = await _get_pool(request)
    clauses = ["ir.tenant_id = $1"]
    params: list[Any] = [_tenant_scope(user)]

    if status_filter and status_filter.lower() != "all":
        params.append(status_filter)
        clauses.append(f"ir.status = ${len(params)}")
    if assigned_to:
        params.append(assigned_to)
        clauses.append(f"ir.assigned_to = ${len(params)}::uuid")

    params.append(limit)
    where = " AND ".join(clauses)

    async with pool.acquire() as conn:
        rows = await conn.fetch(
            f"""
            SELECT ir.reminder_id, ir.reminder_type, ir.title, ir.notes,
                   ir.due_at, ir.status, ir.priority,
                   p.person_id, p.full_name, p.primary_phone
            FROM intel_reminders ir
            INNER JOIN crm_people p
                ON p.person_id = ir.person_id
               AND p.tenant_id = ir.tenant_id
            WHERE {where}
            ORDER BY ir.due_at ASC NULLS LAST, ir.created_at DESC
            LIMIT ${len(params)}
            """,
            *params,
        )

    tasks = [
        {
            "reminder_id": str(r["reminder_id"]),
            "reminder_type": r["reminder_type"],
            "title": r["title"],
            "notes": r["notes"],
            "due_at": r["due_at"].isoformat() if r["due_at"] else None,
            "status": r["status"],
            "priority": r["priority"],
            "person_id": str(r["person_id"]),
            "client_name": r["full_name"],
            "client_phone": r["primary_phone"],
        }
        for r in rows
    ]

    return {"status": "ok", "data": tasks, "meta": {"count": len(tasks)}}


@router.post("/crm/tasks", status_code=201, tags=["CRM Tasks"])
async def create_task(
    request: Request,
    body: CreateReminderRequest,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Create a reminder / follow-up task."""
    pool = await _get_pool(request)
    reminder_id = str(uuid.uuid4())

    async with pool.acquire() as conn:
        due_dt = _parse_optional_datetime(body.due_at, field_name="due_at")

        person = await conn.fetchrow(
            """
            SELECT person_id
            FROM crm_people
            WHERE person_id = $1::uuid
              AND tenant_id = $2
            """,
            body.person_id,
            _tenant_scope(user),
        )
        if not person:
            raise HTTPException(status_code=404, detail=f"Contact '{body.person_id}' not found.")

        if body.lead_id:
            lead = await conn.fetchrow(
                """
                SELECT lead_id
                FROM crm_leads
                WHERE lead_id = $1::uuid
                  AND person_id = $2::uuid
                  AND tenant_id = $3
                """,
                body.lead_id,
                body.person_id,
                _tenant_scope(user),
            )
            if not lead:
                raise HTTPException(status_code=404, detail=f"Lead '{body.lead_id}' not found.")

        await conn.execute(
            """
            INSERT INTO intel_reminders (
                reminder_id, tenant_id, person_id, lead_id, reminder_type, title,
                notes, due_at, status, priority, created_by_type, created_at
            ) VALUES (
                $1::uuid, $2, $3::uuid, $4::uuid, $5, $6, $7, $8, 'pending', $9, 'human', NOW()
            )
            """,
            reminder_id,
            _tenant_scope(user),
            body.person_id,
            body.lead_id,
            body.reminder_type,
            body.title,
            body.notes,
            due_dt,
            body.priority,
        )

    return {"status": "ok", "data": {"reminder_id": reminder_id, "title": body.title}}


@router.patch("/crm/tasks/{reminder_id}", tags=["CRM Tasks"])
async def update_task(
    request: Request,
    reminder_id: str,
    body: UpdateReminderRequest,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Update canonical reminder lifecycle state for an operator-visible task."""
    pool = await _get_pool(request)
    next_status = _normalize_choice(
        body.status,
        allowed=CANONICAL_TASK_STATUSES,
        field_name="task status",
    )
    due_dt = _parse_optional_datetime(body.due_at, field_name="due_at")
    now = datetime.now(timezone.utc)

    if next_status == "snoozed":
        if due_dt is None:
            raise HTTPException(status_code=422, detail="Snoozed reminders require a future 'due_at' timestamp.")
        if due_dt <= now:
            raise HTTPException(status_code=422, detail="Snoozed reminders must be assigned a future 'due_at' timestamp.")

    async with pool.acquire() as conn:
        existing = await conn.fetchrow(
            """
            SELECT reminder_id, status, due_at
            FROM intel_reminders
            WHERE reminder_id = $1::uuid
              AND tenant_id = $2
            """,
            reminder_id,
            _tenant_scope(user),
        )
        if not existing:
            raise HTTPException(status_code=404, detail=f"Task '{reminder_id}' not found.")

        row = await conn.fetchrow(
            """
            UPDATE intel_reminders ir
            SET status = $3,
                due_at = CASE
                    WHEN $4::timestamptz IS NULL THEN ir.due_at
                    ELSE $4::timestamptz
                END,
                notes = COALESCE($5, ir.notes),
                completed_at = CASE
                    WHEN $3 = 'done' THEN COALESCE(ir.completed_at, NOW())
                    WHEN $3 IN ('pending', 'confirmed', 'snoozed', 'cancelled') THEN NULL
                    ELSE ir.completed_at
                END
            FROM crm_people p
            WHERE ir.reminder_id = $1::uuid
              AND ir.tenant_id = $2
              AND p.person_id = ir.person_id
              AND p.tenant_id = ir.tenant_id
            RETURNING ir.reminder_id,
                      ir.reminder_type,
                      ir.title,
                      ir.notes,
                      ir.due_at,
                      ir.status,
                      ir.priority,
                      p.person_id,
                      p.full_name,
                      p.primary_phone
            """,
            reminder_id,
            _tenant_scope(user),
            next_status,
            due_dt,
            body.notes,
        )

    return {
        "status": "ok",
        "data": {
            "reminder_id": str(row["reminder_id"]),
            "reminder_type": row["reminder_type"],
            "title": row["title"],
            "notes": row["notes"],
            "due_at": row["due_at"].isoformat() if row["due_at"] else None,
            "status": row["status"],
            "priority": row["priority"],
            "person_id": str(row["person_id"]),
            "client_name": row["full_name"],
            "client_phone": row["primary_phone"],
        },
        "meta": {
            "previous_status": existing["status"],
            "changed": existing["status"] != row["status"],
        },
    }


# ── Canonical Kanban (from crm_leads) ─────────────────────────────────────

@router.get("/crm/kanban", tags=["CRM Kanban"])
async def get_canonical_kanban(
    request: Request,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """
    Canonical Kanban board from crm_leads table.
    Groups clients by lead status with QD summary.
    """
    pool = await _get_pool(request)

    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT
                cl.lead_id, cl.status, cl.budget_band, cl.urgency,
                p.person_id, p.full_name, p.primary_phone, p.buyer_type,
                COALESCE(qs.intent_value, 0.0) AS intent_score
            FROM crm_leads cl
            INNER JOIN crm_people p
                ON p.person_id = cl.person_id
               AND p.tenant_id = cl.tenant_id
            LEFT JOIN LATERAL (
                SELECT MAX(CASE WHEN score_type = 'intent_score' THEN current_value END) AS intent_value
                FROM intel_qd_scores
                WHERE tenant_id = cl.tenant_id
                  AND person_id = p.person_id
            ) qs ON TRUE
            WHERE cl.tenant_id = $1
            ORDER BY qs.intent_value DESC NULLS LAST, cl.updated_at DESC
            """,
            _tenant_scope(user),
        )

    grouped: dict[str, list[dict]] = {s: [] for s in CANONICAL_LEAD_STAGES}
    for r in rows:
        s = r["status"] or "new"
        grouped.setdefault(s, []).append({
            "lead_id": str(r["lead_id"]),
            "person_id": str(r["person_id"]),
            "client_name": r["full_name"],
            "client_phone": r["primary_phone"],
            "buyer_type": r["buyer_type"],
            "budget_band": r["budget_band"],
            "urgency": r["urgency"],
            "intent_score": float(r["intent_score"]),
        })

    board = [
        {
            "status": s,
            "label": s.replace("_", " ").title(),
            "count": len(grouped.get(s, [])),
            "items": grouped.get(s, []),
        }
        for s in CANONICAL_LEAD_STAGES
    ]
    return {"status": "ok", "data": board}


@router.patch("/crm/leads/{lead_id}/stage", tags=["CRM Kanban"])
async def update_lead_stage(
    request: Request,
    lead_id: str,
    body: UpdateLeadStageRequest,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """Move a canonical CRM lead between funnel stages and persist audit history."""
    pool = await _get_pool(request)
    next_status = _normalize_choice(
        body.status,
        allowed=CANONICAL_LEAD_STAGES,
        field_name="lead stage",
    )
    changed_by = _optional_uuid(user.user_id)

    async with pool.acquire() as conn:
        existing = await conn.fetchrow(
            """
            SELECT cl.lead_id,
                   cl.person_id,
                   cl.status,
                   cl.budget_band,
                   cl.urgency,
                   p.full_name,
                   p.primary_phone
            FROM crm_leads cl
            INNER JOIN crm_people p
                ON p.person_id = cl.person_id
               AND p.tenant_id = cl.tenant_id
            WHERE cl.lead_id = $1::uuid
              AND cl.tenant_id = $2
            """,
            lead_id,
            _tenant_scope(user),
        )
        if not existing:
            raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")

        changed = existing["status"] != next_status
        if changed:
            updated = await conn.fetchrow(
                """
                UPDATE crm_leads
                SET status = $3::crm_lead_status,
                    updated_at = NOW()
                WHERE lead_id = $1::uuid
                  AND tenant_id = $2
                RETURNING lead_id, person_id, status, budget_band, urgency
                """,
                lead_id,
                _tenant_scope(user),
                next_status,
            )
            await conn.execute(
                """
                INSERT INTO crm_stage_history (
                    history_id, lead_id, from_status, to_status, changed_by,
                    changed_by_type, notes, happened_at
                ) VALUES (
                    $1::uuid, $2::uuid, $3, $4, $5::uuid, 'human', $6, NOW()
                )
                """,
                str(uuid.uuid4()),
                lead_id,
                existing["status"],
                next_status,
                changed_by,
                body.notes,
            )
        else:
            updated = existing

    return {
        "status": "ok",
        "data": {
            "lead_id": str(updated["lead_id"]),
            "person_id": str(updated["person_id"]),
            "status": updated["status"],
            "budget_band": updated["budget_band"],
            "urgency": updated["urgency"],
            "client_name": existing["full_name"],
            "client_phone": existing["primary_phone"],
        },
        "meta": {
            "previous_status": existing["status"],
            "changed": changed,
        },
    }


# ── QD Score Access ────────────────────────────────────────────────────────

@router.get("/crm/qd/{person_id}", tags=["CRM QD"])
async def get_qd_score(
    request: Request,
    person_id: str,
    user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
    """QD score summary and recent timeseries for a client."""
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        scores = await conn.fetch(
            """
            SELECT score_type, current_value, computed_at, reasoning
            FROM intel_qd_scores
            WHERE person_id = $1::uuid
              AND tenant_id = $2
            """,
            person_id,
            _tenant_scope(user),
        )
        timeseries = await conn.fetch(
            """
            SELECT score_type, value, timestamp, signal_source, delta
            FROM intel_qd_timeseries
            WHERE person_id = $1::uuid
              AND tenant_id = $2
            ORDER BY timestamp DESC
            LIMIT 50
            """,
            person_id,
            _tenant_scope(user),
        )

    if not scores:
        raise HTTPException(status_code=404, detail=f"No QD scores for client '{person_id}'.")

    return {
        "status": "ok",
        "data": {
            "person_id": person_id,
            "scores": {
                r["score_type"]: {
                    "current_value": float(r["current_value"]),
                    "computed_at": r["computed_at"].isoformat() if r["computed_at"] else None,
                    "reasoning": r["reasoning"],
                }
                for r in scores
            },
            "timeseries": [
                {
                    "score_type": r["score_type"],
                    "value": float(r["value"]),
                    "timestamp": r["timestamp"].isoformat() if r["timestamp"] else None,
                    "signal_source": r["signal_source"],
                    "delta": float(r["delta"]) if r["delta"] else None,
                }
                for r in timeseries
            ],
        },
    }


# ── Oracle Client Data Lens ───────────────────────────────────────────────────

@router.get("/crm/client-data", tags=["CRM Client Data"])
async def list_client_data(
    request: Request,
    search: str | None = Query(default=None),
    limit: int = Query(default=50, ge=1, le=200),
    offset: int = Query(default=0, ge=0),
) -> dict[str, Any]:
    pool = await _get_pool(request)
    params: list[Any] = []
    where = "1=1"
    if search:
        params.append(f"%{search.lower()}%")
        where = f"(lower(p.full_name) LIKE ${len(params)} OR lower(COALESCE(p.primary_phone,'')) LIKE ${len(params)} OR lower(COALESCE(p.primary_email,'')) LIKE ${len(params)} OR lower(COALESCE(pi.projects,'')) LIKE ${len(params)})"
    params.extend([limit, offset])
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            f"""
            WITH interests AS (
                SELECT person_id, string_agg(DISTINCT project_name, ', ') AS projects, COUNT(*)::int AS interest_count
                FROM crm_property_interests GROUP BY person_id
            ),
            qd AS (
                SELECT DISTINCT ON (person_id) person_id, current_value
                FROM intel_qd_scores
                ORDER BY person_id, current_value DESC, computed_at DESC
            )
            SELECT p.person_id::text, p.full_name, p.primary_email, p.primary_phone, p.buyer_type,
                   p.broker_name, p.communication_preference, p.best_contact_time,
                   l.lead_id::text, l.status::text AS lead_status, l.budget_band, l.urgency,
                   COALESCE(qd.current_value, p.engagement_score, 0)::float AS qd_score,
                   lc.last_contact_at, lc.last_channel, lc.days_since_contact,
                   nba.recommended_action AS next_best_action, nba.priority AS next_action_priority,
                   COALESCE(pi.projects, '') AS projects, COALESCE(pi.interest_count, 0)::int AS interest_count
            FROM crm_people p
            LEFT JOIN LATERAL (
                SELECT * FROM crm_leads l WHERE l.person_id = p.person_id ORDER BY l.updated_at DESC LIMIT 1
            ) l ON TRUE
            LEFT JOIN interests pi ON pi.person_id = p.person_id
            LEFT JOIN qd ON qd.person_id = p.person_id
            LEFT JOIN read_last_contacted lc ON lc.person_id = p.person_id
            LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id
            WHERE {where}
            ORDER BY lc.last_contact_at DESC NULLS LAST, qd_score DESC, p.full_name ASC
            LIMIT ${len(params)-1} OFFSET ${len(params)}
            """,
            *params,
        )
    return {"status": "ok", "data": [dict(r) for r in rows], "meta": {"count": len(rows), "limit": limit, "offset": offset}}


@router.get("/crm/client-data/{person_id}", tags=["CRM Client Data"])
async def get_client_data(request: Request, person_id: str) -> dict[str, Any]:
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        base = await conn.fetchrow(
            """
            SELECT p.*, l.lead_id::text, l.status::text AS lead_status, l.budget_band, l.urgency,
                   l.financing_posture, l.timeline_to_decision, l.broker_team,
                   lc.last_contact_at, lc.last_channel, lc.days_since_contact,
                   nba.recommended_action, nba.priority AS next_action_priority, nba.rationale,
                   nba.suggested_channel, nba.due_within_days
            FROM crm_people p
            LEFT JOIN LATERAL (
                SELECT * FROM crm_leads l WHERE l.person_id = p.person_id ORDER BY l.updated_at DESC LIMIT 1
            ) l ON TRUE
            LEFT JOIN read_last_contacted lc ON lc.person_id = p.person_id
            LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id
            WHERE p.person_id = $1::uuid
            """,
            person_id,
        )
        if not base:
            raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
        interests = await conn.fetch("SELECT * FROM crm_property_interests WHERE person_id = $1::uuid ORDER BY priority ASC, created_at DESC", person_id)
        opportunities = await conn.fetch(
            """
            SELECT o.*, ip.project_name FROM crm_opportunities o
            JOIN crm_leads l ON l.lead_id = o.lead_id
            LEFT JOIN inventory_projects ip ON ip.project_id = o.project_id
            WHERE l.person_id = $1::uuid ORDER BY o.updated_at DESC
            """,
            person_id,
        )
        facts = await conn.fetch("SELECT * FROM intel_extracted_facts WHERE person_id = $1::uuid ORDER BY extracted_at DESC LIMIT 50", person_id)
        qd = await conn.fetch("SELECT * FROM intel_qd_scores WHERE person_id = $1::uuid ORDER BY current_value DESC", person_id)
        timeline = await _client_timeline(conn, person_id, 60)
    return {
        "status": "ok",
        "data": {
            "profile": dict(base),
            "property_interests": [dict(r) for r in interests],
            "opportunities": [dict(r) for r in opportunities],
            "extracted_facts": [dict(r) for r in facts],
            "qd_scores": [dict(r) for r in qd],
            "timeline": timeline,
        },
    }


@router.patch("/crm/client-data/{person_id}", tags=["CRM Client Data"])
async def patch_client_data(request: Request, person_id: str, body: ClientDataPatchRequest) -> dict[str, Any]:
    pool = await _get_pool(request)
    payload = body.model_dump(exclude_unset=True)
    person_fields = {k: payload[k] for k in ("full_name", "primary_email", "primary_phone", "buyer_type", "communication_preference", "best_contact_time") if k in payload}
    lead_fields = {k: payload[k] for k in ("budget_band", "urgency") if k in payload}
    async with pool.acquire() as conn:
        async with conn.transaction():
            if person_fields:
                sets = ", ".join(f"{key} = ${idx}" for idx, key in enumerate(person_fields, start=1))
                await conn.execute(f"UPDATE crm_people SET {sets}, updated_at = NOW() WHERE person_id = ${len(person_fields)+1}::uuid", *person_fields.values(), person_id)
            if lead_fields:
                lead_id = await conn.fetchval("SELECT lead_id FROM crm_leads WHERE person_id = $1::uuid ORDER BY updated_at DESC LIMIT 1", person_id)
                if lead_id:
                    sets = ", ".join(f"{key} = ${idx}" for idx, key in enumerate(lead_fields, start=1))
                    await conn.execute(f"UPDATE crm_leads SET {sets}, updated_at = NOW() WHERE lead_id = ${len(lead_fields)+1}::uuid", *lead_fields.values(), str(lead_id))
            if "lead_status" in payload:
                await conn.execute(
                    "UPDATE crm_leads SET status = $1::crm_lead_status, updated_at = NOW() WHERE person_id = $2::uuid",
                    payload["lead_status"],
                    person_id,
                )
    return {"status": "ok", "data": {"person_id": person_id, "updated": sorted(payload.keys())}}


@router.get("/crm/client-data/{person_id}/timeline", tags=["CRM Client Data"])
async def get_client_data_timeline(request: Request, person_id: str, limit: int = Query(default=80, ge=1, le=200)) -> dict[str, Any]:
    pool = await _get_pool(request)
    async with pool.acquire() as conn:
        rows = await _client_timeline(conn, person_id, limit)
    return {"status": "ok", "data": rows}


@router.post("/crm/client-data/{person_id}/tasks", status_code=201, tags=["CRM Client Data"])
async def create_client_data_task(request: Request, person_id: str, body: CreateReminderRequest) -> dict[str, Any]:
    patched = body.model_copy(update={"person_id": person_id})
    return await create_task(request, patched)


async def _client_timeline(conn: Any, person_id: str, limit: int) -> list[dict[str, Any]]:
    rows = await conn.fetch(
        """
        SELECT * FROM (
            SELECT i.interaction_id::text AS id, i.channel::text AS type, i.interaction_type AS title,
                   i.summary, i.happened_at AS date, i.broker_name AS actor
            FROM intel_interactions i WHERE i.person_id = $1::uuid
            UNION ALL
            SELECT m.message_id::text, 'message', m.sender_role, m.message_text, m.delivered_at, m.sender_name
            FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id WHERE i.person_id = $1::uuid
            UNION ALL
            SELECT r.reminder_id::text, 'reminder', r.title, r.notes, r.due_at, r.priority
            FROM intel_reminders r WHERE r.person_id = $1::uuid
            UNION ALL
            SELECT v.visit_id::text, 'visit', COALESCE(v.outcome_type, 'site_visit'), COALESCE(v.visit_notes, v.broker_notes), v.visited_at, v.broker_name
            FROM intel_visits v WHERE v.person_id = $1::uuid
            UNION ALL
            SELECT q.timeseries_id::text, 'qd', q.score_type, q.signal_source, q.timestamp, q.value::text
            FROM intel_qd_timeseries q WHERE q.person_id = $1::uuid
        ) events
        ORDER BY date DESC NULLS LAST
        LIMIT $2
        """,
        person_id,
        limit,
    )
    return [dict(r) for r in rows]
