from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from typing import List, Optional, Dict, Any
import copy

from ...db import get_session
from ...auth_security import require_account, require_user
from .l2a import make_role_dependency
from ...services.salesforce_gateway import SalesforceGateway, get_salesforce_gateway
from ...model_defs.l2a_models import L2APolicy
from ...model_defs.c2a_policy import C2APolicy
from ...model_defs.c2a_models import C2ASuggestion, C2AJob
from ...model_defs.saved_views import ManualSavedView
from ...services.sf_field_catalog import discover_contact_catalog
import asyncio
import json
from datetime import datetime, timedelta
from fastapi.responses import StreamingResponse


router = APIRouter(prefix="/api/v2/c2a", tags=["c2a"])

# Pre-baked guard matching L2A pattern
require_admin_or_manager = make_role_dependency("admin", "manager")


# ===== Policy (GET/POST) =====


class C2APolicyBody(BaseModel):
    threshold: Optional[float] = None
    min_score_gap: Optional[float] = None
    use_multi_algo: Optional[bool] = None
    link_writeback_mode: Optional[str] = None
    link_overwrite_mode: Optional[str] = None
    registry_version: Optional[str] = None
    allow_upgrade: Optional[bool] = None


async def _ensure_c2a_policy(db: AsyncSession, account_id: str) -> C2APolicy:
    row = (
        await db.execute(
            select(C2APolicy).where(C2APolicy.account_id == str(account_id)).limit(1)
        )
    ).scalar_one_or_none()
    if row:
        return row
    row = C2APolicy(account_id=str(account_id))
    db.add(row)
    await db.commit()
    await db.refresh(row)
    return row


@router.get("/policy")
async def c2a_policy_get(
    db: AsyncSession = Depends(get_session), account_id: str = Depends(require_account)
):
    pol = await _ensure_c2a_policy(db, account_id)
    return {
        "id": pol.id,
        "account_id": pol.account_id,
        "link_writeback_mode": pol.link_writeback_mode,
        "link_overwrite_mode": pol.link_overwrite_mode,
        "use_multi_algo": pol.use_multi_algo,
        "threshold": pol.threshold,
        "min_score_gap": pol.min_score_gap,
        "weights_json": pol.weights_json,
        "auto_link_tier": pol.auto_link_tier,
        "allow_upgrade": pol.allow_upgrade,
        "registry_version": pol.registry_version,
        "updated_at": pol.updated_at.isoformat() if pol.updated_at else None,
    }


@router.post("/policy", dependencies=[Depends(require_admin_or_manager)])
async def c2a_policy_set(
    body: C2APolicyBody,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    pol = await _ensure_c2a_policy(db, account_id)
    if body.threshold is not None:
        pol.threshold = float(body.threshold)
    if body.min_score_gap is not None:
        pol.min_score_gap = float(body.min_score_gap)
    if body.use_multi_algo is not None:
        pol.use_multi_algo = bool(body.use_multi_algo)
    if body.link_writeback_mode is not None:
        pol.link_writeback_mode = str(body.link_writeback_mode)
    if body.link_overwrite_mode is not None:
        pol.link_overwrite_mode = str(body.link_overwrite_mode)
    if body.registry_version is not None:
        pol.registry_version = str(body.registry_version)
    if body.allow_upgrade is not None:
        pol.allow_upgrade = bool(body.allow_upgrade)
    db.add(pol)
    await db.commit()
    await db.refresh(pol)
    return {"ok": True, "id": pol.id}


class FilterChip(BaseModel):
    field: str
    op: str
    value: Optional[str] = None


class SortSpec(BaseModel):
    field: str
    dir: str = "asc"


class ContactSearchBody(BaseModel):
    filters: List[FilterChip] = []
    page_size: int = 50
    cursor: Optional[str] = None
    sort: Optional[SortSpec] = None


class AccountSearchBody(BaseModel):
    q: Optional[str] = None
    filters: List[FilterChip] = []
    page_size: int = 50
    cursor: Optional[str] = None


def _escape_like(value: str) -> str:
    s = value or ""
    return s.replace("%", "\\%").replace("_", "\\_").replace("'", "\\'").strip()


async def _field_types(sf: SalesforceGateway, obj: str) -> dict:
    desc = await sf.describe(obj)
    return {
        str(f.get("name") or ""): str(f.get("type") or "")
        for f in desc.get("fields", [])
    }


def _where(filters: List[FilterChip], types: dict) -> List[str]:
    parts: List[str] = []
    for f in filters or []:
        if f.field not in types:
            continue
        t = (types.get(f.field) or "").lower()
        op = (f.op or "").lower()
        v = f.value
        if op == "equals":
            parts.append(f"{f.field} = '{_escape_like(v or '')}'")
        elif op == "not_equals":
            parts.append(f"{f.field} != '{_escape_like(v or '')}'")
        elif op == "contains" and t in {"string", "email", "url", "phone"}:
            parts.append(f"{f.field} LIKE '%{_escape_like(v or '')}%'")
        elif op == "starts_with" and t in {"string", "email", "url", "phone"}:
            parts.append(f"{f.field} LIKE '{_escape_like(v or '')}%'")
        elif op == "is_empty":
            if t in {"string", "email", "url", "phone", "picklist", "multipicklist"}:
                parts.append(f"({f.field} = NULL OR {f.field} = '')")
            else:
                parts.append(f"({f.field} = NULL)")
        elif op in {"before", "after", "on"} and t in {"date", "datetime"}:
            comp = {"before": "<", "after": ">", "on": "="}[op]
            parts.append(f"{f.field} {comp} '{_escape_like(v or '')}'")
        elif op == "last_n_days" and t in {"date", "datetime"}:
            try:
                n = int(v or 0)
                parts.append(f"{f.field} = LAST_N_DAYS:{n}")
            except Exception:
                pass
        else:
            parts.append(f"{f.field} = '{_escape_like(v or '')}'")
    return parts


async def _select_fields_contact(sf: SalesforceGateway) -> List[str]:
    desc = await sf.describe("Contact")
    avail = {
        str((f.get("name") or "")).lower(): (f.get("name") or "")
        for f in desc.get("fields", [])
    }
    base = [
        "Id",
        "FirstName",
        "LastName",
        "Email",
        "Department",
        "MailingCity",
        "MailingCountry",
        "Phone",
        "AccountId",
        "OwnerId",
        "CreatedDate",
    ]
    out = [avail[x.lower()] for x in base if x.lower() in avail]
    return out or ["Id"]


async def _select_fields_account(sf: SalesforceGateway) -> List[str]:
    desc = await sf.describe("Account")
    avail = {
        str((f.get("name") or "")).lower(): (f.get("name") or "")
        for f in desc.get("fields", [])
    }
    base = ["Id", "Name", "Website", "Industry", "BillingCountry"]
    return [avail[x.lower()] for x in base if x.lower() in avail] or ["Id"]


# ====== Filters Catalog for Contact (promoted picklists) ======


@router.get("/manual/filters/catalog")
async def c2a_filter_catalog(sf: SalesforceGateway = Depends(get_salesforce_gateway)):
    desc = await sf.describe("Contact")
    return discover_contact_catalog(desc)


@router.get("/manual/filters/picklist/{field_api}")
async def c2a_picklist_values(
    field_api: str, sf: SalesforceGateway = Depends(get_salesforce_gateway)
):
    desc = await sf.describe("Contact")
    fmap = {str(f.get("name") or ""): f for f in desc.get("fields", [])}
    f = fmap.get(field_api)
    if not f or str(f.get("type") or "").lower() not in ("picklist", "multipicklist"):
        return {"field": field_api, "values": []}
    vals = [
        v.get("value")
        for v in (f.get("picklistValues") or [])
        if v and v.get("active", True)
    ]
    return {"field": field_api, "values": [x for x in vals if x]}


# ====== Saved Views (Contact) ======


@router.get("/manual/views")
async def c2a_views_list(
    scope: str = Query("user", pattern=r"^(user|org)$"),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    base = (
        select(ManualSavedView)
        .where(
            ManualSavedView.account_id == str(account_id),
            ManualSavedView.object_type == "Contact",
        )
        .order_by(ManualSavedView.updated_at.desc())
    )
    if scope == "org":
        q = base.where(ManualSavedView.scope == "org")
    else:
        q = base.where(
            (ManualSavedView.scope == "org")
            | (
                (ManualSavedView.scope == "user")
                & (ManualSavedView.user_id == str(user_id))
            )
        )
    rows = (await db.execute(q)).scalars().all()
    out = []
    for r in rows:
        try:
            filters = json.loads(r.filters_json or "[]")
        except Exception:
            filters = []
        try:
            settings = json.loads(r.settings_json or "{}")
        except Exception:
            settings = {}
        out.append(
            {
                "id": r.id,
                "name": r.name,
                "scope": r.scope,
                "filters": filters,
                "settings": settings,
            }
        )
    return out


class ViewSaveBody(BaseModel):
    name: str
    scope: str = "user"
    filters: List[Dict[str, Any]] = []
    settings: Dict[str, Any] = {}


@router.post("/manual/views")
async def c2a_view_save(
    body: ViewSaveBody,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    row = ManualSavedView(
        account_id=str(account_id),
        user_id=str(user_id),
        object_type="Contact",
        name=body.name,
        scope=body.scope or "user",
        filters_json=json.dumps(body.filters or []),
        settings_json=json.dumps(body.settings or {}),
    )
    db.add(row)
    await db.commit()
    return {"id": row.id}


@router.patch("/manual/views/{view_id}")
async def c2a_view_update(
    view_id: str,
    body: ViewSaveBody,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    row = await db.get(ManualSavedView, view_id)
    if not row or row.account_id != str(account_id) or row.object_type != "Contact":
        raise HTTPException(404, "View not found")
    if row.scope == "user" and row.user_id != str(user_id):
        raise HTTPException(403, "Cannot modify another user's view")
    if body.name:
        row.name = body.name
    if body.scope:
        row.scope = body.scope
    row.filters_json = json.dumps(body.filters or [])
    row.settings_json = json.dumps(body.settings or {})
    await db.commit()
    return {"ok": True}


@router.delete("/manual/views/{view_id}")
async def c2a_view_delete(
    view_id: str,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    row = await db.get(ManualSavedView, view_id)
    if not row or row.account_id != str(account_id) or row.object_type != "Contact":
        return {"ok": True}
    if row.scope == "user" and row.user_id != str(user_id):
        raise HTTPException(403, "Cannot delete another user's view")
    await db.delete(row)
    await db.commit()
    return {"ok": True}


@router.post("/manual/contacts/search")
async def contacts_search(
    body: ContactSearchBody,
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
):
    if body.cursor:
        page = await sf.fetch_next(body.cursor)
        return {
            "items": page.get("records", []),
            "next_cursor": page.get("nextRecordsUrl"),
        }

    types = await _field_types(sf, "Contact")
    defaults = ["(AccountId = NULL)"]
    where = _where(body.filters or [], types)
    clause = " WHERE " + " AND ".join(defaults + where) if (defaults or where) else ""
    order = ""
    if body.sort and body.sort.field in types:
        d = "DESC" if (body.sort.dir or "").lower() == "desc" else "ASC"
        order = f" ORDER BY {body.sort.field} {d}"
    limit = max(1, min(int(body.page_size or 50), 500))
    fields = ", ".join(await _select_fields_contact(sf))
    soql = f"SELECT {fields} FROM Contact{clause}{order} LIMIT {limit}"
    res = await sf.soql(soql)
    return {"items": res.get("records", []), "next_cursor": res.get("nextRecordsUrl")}


class CountBody(BaseModel):
    filters: List[FilterChip] = []
    q: Optional[str] = None


@router.post("/manual/contacts/count")
async def contacts_count(
    body: CountBody,
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
):
    types = await _field_types(sf, "Contact")
    defaults = ["(AccountId = NULL)"]
    where = _where(body.filters or [], types)
    clause = " WHERE " + " AND ".join(defaults + where) if (defaults or where) else ""
    soql = f"SELECT COUNT() FROM Contact{clause}"
    res = await sf.soql(soql)
    return {"count": res.get("totalSize", 0)}


@router.post("/manual/accounts/count")
async def accounts_count(
    body: CountBody,
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
):
    types = await _field_types(sf, "Account")
    where = _where(body.filters or [], types)
    if body.q:
        q = _escape_like(body.q)
        ors: List[str] = []
        for f in ["Name", "Website"]:
            if f in types:
                ors.append(f"{f} LIKE '%{q}%'")
        if "Domain__c" in types:
            ors.append(f"Domain__c LIKE '%{q}%'")
        if ors:
            where.append("(" + " OR ".join(ors) + ")")
    clause = (" WHERE " + " AND ".join(where)) if where else ""
    soql = f"SELECT COUNT() FROM Account{clause}"
    res = await sf.soql(soql)
    return {"count": res.get("totalSize", 0)}


@router.post("/manual/accounts/search")
async def accounts_search(
    body: AccountSearchBody, sf: SalesforceGateway = Depends(get_salesforce_gateway)
):
    if body.cursor:
        page = await sf.fetch_next(body.cursor)
        return {
            "items": page.get("records", []),
            "next_cursor": page.get("nextRecordsUrl"),
        }
    types = await _field_types(sf, "Account")
    where = _where(body.filters or [], types)
    if body.q:
        q = _escape_like(body.q)
        ors: List[str] = []
        for f in ["Name", "Website"]:
            if f in types:
                ors.append(f"{f} LIKE '%{q}%'")
        if "Domain__c" in types:
            ors.append(f"Domain__c LIKE '%{q}%'")
        if ors:
            where.append("(" + " OR ".join(ors) + ")")
    clause = (" WHERE " + " AND ".join(where)) if where else ""
    limit = max(1, min(int(body.page_size or 50), 500))
    fields = ", ".join(await _select_fields_account(sf))
    soql = f"SELECT {fields} FROM Account{clause} LIMIT {limit}"
    res = await sf.soql(soql)
    return {"items": res.get("records", []), "next_cursor": res.get("nextRecordsUrl")}


class PreviewBody(BaseModel):
    contact_ids: List[str]
    account_id: str
    writeback_mode: str = "account"  # 'account' or 'acr'


@router.post("/manual/preview")
async def c2a_preview(
    body: PreviewBody,
    db: AsyncSession = Depends(get_session),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    account_id: str = Depends(require_account),
):
    # Validate target account exists
    try:
        _ = await sf.get_record("Account", body.account_id, fields=["Id"])
    except Exception:
        raise HTTPException(400, "Account not found")

    # Load policy for overwrite mode
    # Prefer C2A policy; fallback to L2A policy for legacy tenants
    pol = (
        await db.execute(
            select(C2APolicy).where(C2APolicy.account_id == str(account_id)).limit(1)
        )
    ).scalar_one_or_none()
    if pol is None:
        l2a_pol = (
            await db.execute(
                select(L2APolicy)
                .where(L2APolicy.account_id == str(account_id))
                .limit(1)
            )
        ).scalar_one_or_none()
        if not l2a_pol:
            raise HTTPException(400, "No active policy")
        mode = l2a_pol.link_overwrite_mode or "if_empty"
    else:
        mode = pol.link_overwrite_mode or "if_empty"

    # FLS checks
    if body.writeback_mode == "account":
        descC = await sf.describe("Contact")
        f_by = {f.get("name"): f for f in descC.get("fields", [])}
        if "AccountId" not in f_by or not f_by["AccountId"].get("updateable", False):
            raise HTTPException(400, "Contact.AccountId not updateable")
    else:
        try:
            _ = await sf.describe("AccountContactRelation")
        except Exception:
            raise HTTPException(400, "AccountContactRelation not available")

    diffs = []
    for cid in list(dict.fromkeys(body.contact_ids or [])):
        prev = None
        blocked = None
        reason = "assign"
        if body.writeback_mode == "account":
            try:
                c = await sf.get_record("Contact", cid, fields=["AccountId"])
                prev = c.get("AccountId")
            except Exception:
                prev = None
            if mode == "never" and prev:
                blocked = "Field already populated (mode=never)"
            elif mode == "if_empty" and prev and prev != body.account_id:
                blocked = f"Field already set to {prev} (mode=if_empty)"
            if prev and prev != body.account_id:
                reason = "upgrade"
                # Respect allow_upgrade policy if present
                try:
                    if (
                        pol
                        and hasattr(pol, "allow_upgrade")
                        and not bool(getattr(pol, "allow_upgrade"))
                    ):
                        blocked = "Upgrade blocked by policy (allow_upgrade=false)"
                except Exception:
                    pass
        diffs.append(
            {
                "contact_id": cid,
                "field": "AccountId"
                if body.writeback_mode == "account"
                else "AccountContactRelation",
                "before": prev,
                "after": body.account_id,
                **({"blocked_reason": blocked} if blocked else {}),
                "reason_code": reason,
            }
        )
    return {
        "diffs": diffs,
        "overwrite_mode": mode,
        "writeback_mode": body.writeback_mode,
    }


class SuggestionsBody(BaseModel):
    contact_ids: List[str]
    account_id: str
    writeback_mode: str = "account"


@router.post("/manual/suggestions")
async def c2a_suggestions(
    body: SuggestionsBody,
    db: AsyncSession = Depends(get_session),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    account_id: str = Depends(require_account),
):
    pol = (
        await db.execute(
            select(C2APolicy).where(C2APolicy.account_id == str(account_id)).limit(1)
        )
    ).scalar_one_or_none()
    if pol is None:
        l2a_pol = (
            await db.execute(
                select(L2APolicy)
                .where(L2APolicy.account_id == str(account_id))
                .limit(1)
            )
        ).scalar_one_or_none()
        if not l2a_pol:
            raise HTTPException(400, "No active policy")
        mode = l2a_pol.link_overwrite_mode or "if_empty"
    else:
        mode = pol.link_overwrite_mode or "if_empty"

    # FLS checks
    if body.writeback_mode == "account":
        descC = await sf.describe("Contact")
        f_by = {f.get("name"): f for f in descC.get("fields", [])}
        if "AccountId" not in f_by or not f_by["AccountId"].get("updateable", False):
            raise HTTPException(400, "Contact.AccountId not updateable")
    else:
        try:
            _ = await sf.describe("AccountContactRelation")
        except Exception:
            raise HTTPException(400, "AccountContactRelation not available")

    created: List[str] = []
    skipped = []
    for cid in list(dict.fromkeys(body.contact_ids or [])):
        prev = None
        block = None
        reason = "assign"
        inc = None
        if body.writeback_mode == "account":
            try:
                c = await sf.get_record("Contact", cid, fields=["AccountId"])
                prev = c.get("AccountId")
            except Exception:
                prev = None
            if mode == "never" and prev:
                block = "Field already populated (mode=never)"
            elif mode == "if_empty" and prev and prev != body.account_id:
                block = f"Field already set to {prev} (mode=if_empty)"
            if prev and prev != body.account_id:
                reason = "upgrade"
                inc = prev
                # Block if upgrades disallowed by policy
                try:
                    if (
                        pol
                        and hasattr(pol, "allow_upgrade")
                        and not bool(getattr(pol, "allow_upgrade"))
                    ):
                        block = "Upgrade blocked by policy (allow_upgrade=false)"
                except Exception:
                    pass
        if block:
            skipped.append({"contact_id": cid, "reason": block})
            continue
        s = C2ASuggestion(
            policy_id=pol.id,
            contact_id=cid,
            account_id=body.account_id,
            explanation="manual",
            status="pending",
            reason_code=reason,
            incumbent_account_id=inc,
        )
        db.add(s)
        await db.flush()
        created.append(s.id)
    await db.commit()
    return {"suggestion_ids": created, "created": len(created), "skipped": skipped}


@router.post("/apply")
async def c2a_apply(
    ids: List[str],
    db: AsyncSession = Depends(get_session),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    writeback_mode: str = "account",
):
    applied = 0
    errors = []
    for sid in ids or []:
        s = await db.get(C2ASuggestion, sid)
        if not s or s.status != "pending":
            continue
        try:
            if writeback_mode == "account":
                await sf.update("Contact", s.contact_id, {"AccountId": s.account_id})
                s.applied_field = "AccountId"
                s.previous_value = s.incumbent_account_id
                s.applied_value = s.account_id
                s.relation_id = None
            else:
                payload = {
                    "ContactId": s.contact_id,
                    "AccountId": s.account_id,
                    "IsActive": True,
                    "IsDirect": False,
                }
                result = await sf.create("AccountContactRelation", payload)
                s.applied_field = "AccountContactRelation"
                s.previous_value = None
                s.applied_value = s.account_id
                try:
                    s.relation_id = (
                        (result or {}).get("id") if isinstance(result, dict) else None
                    )
                except Exception:
                    s.relation_id = None
            s.status = "linked"
            applied += 1
        except Exception as e:
            s.status = "error"
            try:
                s.error_message = str(e)
            except Exception:
                s.error_message = "error"
            # Increment job error_count when suggestion belongs to a job
            try:
                if getattr(s, "job_id", None):
                    j = await db.get(C2AJob, s.job_id)
                    if j:
                        j.error_count = int(getattr(j, "error_count", 0) or 0) + 1
                        db.add(j)
            except Exception:
                pass
            errors.append({"id": sid, "message": str(e)})
        finally:
            db.add(s)
            await db.commit()
    return {"applied": applied, "errors": errors}


# ========== Auto Runner + Status + Suggestions ==========
class RunBody(BaseModel):
    threshold: float = 0.7
    b2c_skip: bool = True
    mode: str = "assign"
    sample_limit: Optional[int] = None
    contact_ids: Optional[List[str]] = None


@router.post("/run")
async def c2a_run(
    body: RunBody,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    # Read policy for writeback/overwrite defaults
    pol = (
        await db.execute(
            select(C2APolicy).where(C2APolicy.account_id == str(account_id)).limit(1)
        )
    ).scalar_one_or_none()
    writeback_field = (
        "AccountId"
        if not pol or (pol.link_writeback_mode or "account") == "account"
        else "AccountContactRelation"
    )
    overwrite_mode = (pol.link_overwrite_mode if pol else "if_empty") or "if_empty"

    job = C2AJob(
        account_id=str(account_id),
        user_id=str(user_id),
        status="queued",
        kind="c2a_bulk_link",
        writeback_field=writeback_field,
        overwrite_mode=overwrite_mode,
        params_json=json.dumps(body.model_dump()),
    )
    db.add(job)
    await db.commit()
    await db.refresh(job)

    # Defer import to avoid circulars
    from ...services.c2a_service_intelligent import run_c2a_job

    # Start background task
    asyncio.create_task(run_c2a_job(job.id))
    return {"job_id": job.id}


@router.get("/status/{job_id}")
async def c2a_status(
    job_id: str,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    row = await db.get(C2AJob, job_id)
    if not row or row.account_id != str(account_id):
        raise HTTPException(404, "Job not found")
    pct = (
        0
        if not row.total
        else min(100, int(100 * (row.processed or 0) / max(1, row.total)))
    )
    return {
        "status": row.status,
        "progress_pct": pct,
        "total": row.total,
        "processed": row.processed,
        "applied": getattr(row, "applied", 0),
        "skipped": getattr(row, "skipped", 0),
        "errors": getattr(row, "error_count", 0),
        "eta_sec": getattr(row, "eta_sec", 0),
        "blocked_if_empty": getattr(row, "blocked_if_empty", 0),
        "blocked_never": getattr(row, "blocked_never", 0),
        "skipped_b2c": getattr(row, "skipped_b2c", 0),
        "conflicts": getattr(row, "conflicts", 0),
        "created_at": row.created_at.isoformat() if row.created_at else None,
        "updated_at": row.updated_at.isoformat() if row.updated_at else None,
    }


def _safe_json(text: Optional[str]):
    if not text:
        return None
    try:
        return json.loads(text)
    except Exception:
        return None


@router.get("/suggestions")
async def c2a_suggestions(
    status: str = "pending",
    min_score: Optional[float] = None,
    page: int = 1,
    page_size: int = 50,
    hydrate: bool = True,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
):
    q = (
        select(C2ASuggestion)
        .where(C2ASuggestion.status == status)
        .order_by(C2ASuggestion.created_at.desc())
        .limit(page_size)
        .offset(max(0, page - 1) * page_size)
    )
    if min_score is not None:
        q = q.where(C2ASuggestion.score >= float(min_score))

    rows = (await db.execute(q)).scalars().all()
    total = (
        await db.execute(
            select(func.count())
            .select_from(C2ASuggestion)
            .where(C2ASuggestion.status == status)
        )
    ).scalar_one()

    # Batch fetch Salesforce data for hydration
    contact_map = {}
    account_map = {}

    if hydrate and sf and rows:
        # Collect unique IDs
        contact_ids = list(set(r.contact_id for r in rows if r.contact_id))
        account_ids = list(set(r.account_id for r in rows if r.account_id))

        base_allowlist = {
            "Contact": [
                "Id",
                "Name",
                "FirstName",
                "LastName",
                "Email",
                "Phone",
                "MobilePhone",
                "Title",
            ],
            "Account": [
                "Id",
                "Name",
                "Website",
                "Phone",
                "Industry",
            ],
        }

        # Batch fetch contacts
        if contact_ids:
            try:
                contact_ids_clause = ",".join(f"'{cid}'" for cid in contact_ids[:200])
                contact_query = (
                    "SELECT Id, Name, FirstName, LastName, Email, Phone, MobilePhone, Title "
                    f"FROM Contact WHERE Id IN ({contact_ids_clause}) LIMIT 200"
                )
                contacts_result = await sf.soql(
                    contact_query, plan_allowlist=copy.deepcopy(base_allowlist)
                )
                if contacts_result and contacts_result.get("records"):
                    for contact in contacts_result["records"]:
                        contact_map[contact["Id"]] = contact
            except Exception as e:
                import logging

                logging.getLogger(__name__).error(
                    f"Could not batch fetch contacts: {e}"
                )

        # Batch fetch accounts
        if account_ids:
            try:
                account_ids_clause = ",".join(f"'{aid}'" for aid in account_ids[:200])
                account_query = (
                    "SELECT Id, Name, Website, Phone, Industry "
                    f"FROM Account WHERE Id IN ({account_ids_clause}) LIMIT 200"
                )
                accounts_result = await sf.soql(
                    account_query, plan_allowlist=copy.deepcopy(base_allowlist)
                )
                if accounts_result and accounts_result.get("records"):
                    for account in accounts_result["records"]:
                        account_map[account["Id"]] = account
            except Exception as e:
                import logging

                logging.getLogger(__name__).error(
                    f"Could not batch fetch accounts: {e}"
                )

    items = []
    for r in rows:
        try:
            reasons = _safe_json(getattr(r, "reasons", None))
            if not isinstance(reasons, list):
                reasons = [r.reason_code] if r.reason_code else []
        except Exception:
            reasons = [r.reason_code] if r.reason_code else []

        # Get hydrated data
        contact = contact_map.get(r.contact_id, {})
        account = account_map.get(r.account_id, {})

        # Build contact name
        first_name = (contact.get("FirstName") or "").strip()
        last_name = (contact.get("LastName") or "").strip()
        contact_name = " ".join(filter(None, [first_name, last_name]))
        if not contact_name:
            contact_name = (contact.get("Name") or "").strip()
        if not contact_name:
            contact_name = f"Contact {r.contact_id[:10]}"

        items.append(
            {
                "id": r.id,
                "contact_id": r.contact_id,
                "account_id": r.account_id,
                "contact_name": contact_name,
                "contact_email": contact.get("Email", ""),
                "account_name": account.get("Name") or f"Account {r.account_id[:10]}",
                "account_website": account.get("Website", ""),
                "account_phone": account.get("Phone"),
                "score": r.score,
                "score_gap": getattr(r, "score_gap", None),
                "tier": getattr(r, "tier", None),
                "status": r.status,
                "explanations": _safe_json(r.explanation) or {},
                "reasons": reasons,
                "field_score_details": _safe_json(
                    getattr(r, "field_score_details", None)
                )
                or {},
                "registry_version": getattr(r, "registry_version", None),
                "gain": getattr(r, "gain", None),
            }
        )
    return {"items": items, "total": total, "page": page, "page_size": page_size}


# ===== Jobs list & SSE =====


@router.get("/jobs", dependencies=[Depends(require_admin_or_manager)])
async def c2a_jobs_list(
    db: AsyncSession = Depends(get_session), account_id: str = Depends(require_account)
):
    rows = (
        (
            await db.execute(
                select(C2AJob)
                .where(C2AJob.account_id == str(account_id))
                .order_by(C2AJob.created_at.desc())
            )
        )
        .scalars()
        .all()
    )
    out = []
    for j in rows:
        pct = (
            0
            if not j.total
            else min(100, int(100 * (j.processed or 0) / max(1, j.total)))
        )
        out.append(
            {
                "id": j.id,
                "status": j.status,
                "approval_status": getattr(j, "approval_status", None),
                "total": j.total,
                "processed": j.processed,
                "applied": getattr(j, "applied", 0),
                "skipped": getattr(j, "skipped", 0),
                "errors": getattr(j, "error_count", 0),
                "eta_sec": getattr(j, "eta_sec", 0),
                "blocked_if_empty": getattr(j, "blocked_if_empty", 0),
                "blocked_never": getattr(j, "blocked_never", 0),
                "skipped_b2c": getattr(j, "skipped_b2c", 0),
                "conflicts": getattr(j, "conflicts", 0),
                "progress_pct": pct,
                "created_at": j.created_at.isoformat() if j.created_at else None,
                "updated_at": j.updated_at.isoformat() if j.updated_at else None,
            }
        )
    return out


@router.get("/jobs/{job_id}/events")
async def c2a_job_events(
    job_id: str,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    async def gen():
        last = None
        while True:
            row = await db.get(C2AJob, job_id)
            if not row or row.account_id != str(account_id):
                yield f"event: done\ndata: {json.dumps({'error':'not_found'})}\n\n"
                break
            cur = {
                "status": row.status,
                "progress_pct": 0
                if not row.total
                else min(100, int(100 * (row.processed or 0) / max(1, row.total))),
                "processed": row.processed,
                "total": row.total,
                "applied": getattr(row, "applied", 0),
                "skipped": getattr(row, "skipped", 0),
                "errors": getattr(row, "error_count", 0),
                "skipped_b2c": getattr(row, "skipped_b2c", 0),
                "blocked_if_empty": getattr(row, "blocked_if_empty", 0),
                "blocked_never": getattr(row, "blocked_never", 0),
                "eta_sec": getattr(row, "eta_sec", 0),
                "updated_at": row.updated_at.isoformat() if row.updated_at else None,
            }
            if cur != last:
                yield f"data: {json.dumps(cur)}\n\n"
                last = cur
            if row.status in ("completed", "failed", "canceled"):
                yield "event: done\ndata: {}\n\n"
                break
            await asyncio.sleep(1.0)

    return StreamingResponse(gen(), media_type="text/event-stream")


@router.post("/jobs/{job_id}/approve", dependencies=[Depends(require_admin_or_manager)])
async def c2a_job_approve(
    job_id: str,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    job = await db.get(C2AJob, job_id)
    if not job or job.account_id != str(account_id):
        raise HTTPException(404, "Job not found")
    if job.status != "waiting_approval":
        raise HTTPException(400, "Job not awaiting approval")
    job.status = "queued"
    job.approval_status = "approved"
    job.approved_by = str(user_id)
    job.approved_via = "api"
    job.updated_at = datetime.utcnow()
    await db.commit()
    # Enqueue with per-tenant concurrency guard
    from ... import settings

    async def _enqueue_c2a_if_capacity(jid: str):
        j = await db.get(C2AJob, jid)
        if not j:
            return {"ok": False}
        running = (
            await db.execute(
                select(func.count())
                .select_from(C2AJob)
                .where(C2AJob.account_id == j.account_id, C2AJob.status == "running")
            )
        ).scalar_one()
        limit = int(getattr(settings, "C2A_BULK_CONCURRENCY_LIMIT", 1) or 1)
        if running >= limit:
            return {"queued": True}
        # move queued -> running and start
        j.status = "running"
        j.updated_at = datetime.utcnow()
        await db.commit()
        from ...services.c2a_service_intelligent import run_c2a_job

        asyncio.create_task(run_c2a_job(jid))
        # Audit: record approval action
        try:
            from ...intelligence.decision_logger import DecisionLogger

            dl = DecisionLogger()
            await dl.log_feedback(
                job_id=jid, match_id=jid, outcome="accepted", user_id=str(user_id)
            )
        except Exception:
            pass
        return {"started": True}

    return await _enqueue_c2a_if_capacity(job.id)


# ===== Dry Run (manual bulk link) =====
class BulkLinkStart(BaseModel):
    account_id: str
    contact_ids: List[str] = []
    writeback_mode: str = "account"  # 'account' | 'acr'


@router.post(
    "/manual/bulk/link/dry-run", dependencies=[Depends(require_admin_or_manager)]
)
async def c2a_dry_run(
    body: BulkLinkStart,
    db: AsyncSession = Depends(get_session),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    pol = await _ensure_c2a_policy(db, account_id)
    overwrite_mode = pol.link_overwrite_mode or "if_empty"
    would_process = 0
    would_apply = 0
    blocked_if_empty = 0
    blocked_never = 0

    ids = list(dict.fromkeys(body.contact_ids or []))
    for cid in ids:
        would_process += 1
        if body.writeback_mode == "acr":
            # ACR creation does not block on existing AccountId
            would_apply += 1
            continue
        prev = None
        try:
            c = await sf.get_record("Contact", cid, fields=["AccountId"])
            prev = c.get("AccountId")
        except Exception:
            prev = None
        if overwrite_mode == "never" and prev:
            blocked_never += 1
            continue
        if overwrite_mode == "if_empty" and prev and prev != body.account_id:
            blocked_if_empty += 1
            continue
        would_apply += 1

    return {
        "would_process": would_process,
        "would_apply": would_apply,
        "blocked_if_empty": blocked_if_empty,
        "blocked_never": blocked_never,
    }


@router.post("/jobs/{job_id}/cancel", dependencies=[Depends(require_admin_or_manager)])
async def c2a_job_cancel(
    job_id: str,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    job = await db.get(C2AJob, job_id)
    if not job or job.account_id != str(account_id):
        raise HTTPException(404, "Job not found")
    job.status = "canceled"
    job.updated_at = datetime.utcnow()
    await db.commit()
    return {"ok": True}


@router.post("/jobs/{job_id}/retry", dependencies=[Depends(require_admin_or_manager)])
async def c2a_job_retry(
    job_id: str,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    prev = await db.get(C2AJob, job_id)
    if not prev or prev.account_id != str(account_id):
        raise HTTPException(404, "Job not found")
    new_job = C2AJob(
        account_id=prev.account_id,
        user_id=prev.user_id,
        status="queued",
        params_json=prev.params_json,
    )
    db.add(new_job)
    await db.commit()
    from ...services.c2a_service_intelligent import run_c2a_job

    asyncio.create_task(run_c2a_job(new_job.id))
    return {"job_id": new_job.id}


@router.post("/jobs/{job_id}/undo", dependencies=[Depends(require_admin_or_manager)])
async def c2a_job_undo(
    job_id: str,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
):
    from ...services.c2a_writeback import undo_accountid, undo_acr
    from ... import settings

    cutoff = datetime.utcnow() - timedelta(
        hours=getattr(settings, "C2A_UNDO_TTL_HOURS", 24)
    )
    q = select(C2ASuggestion).where(
        C2ASuggestion.job_id == job_id,
        C2ASuggestion.status == "linked",
        C2ASuggestion.updated_at >= cutoff,
    )
    rows = (await db.execute(q)).scalars().all()
    undone = 0
    errors = 0
    for s in rows:
        try:
            if s.applied_field == "AccountId":
                await undo_accountid(sf, s.contact_id, s.previous_value)
            else:
                await undo_acr(
                    sf, s.relation_id, s.contact_id, s.applied_value or s.account_id
                )
            s.status = "pending"
            s.updated_at = datetime.utcnow()
            db.add(s)
            undone += 1
        except Exception:
            errors += 1
    await db.commit()
    # Audit: record undo events
    try:
        from ...intelligence.decision_logger import DecisionLogger

        dl = DecisionLogger()
        for s in rows:
            try:
                await dl.log_feedback(
                    job_id=job_id,
                    match_id=s.id,
                    outcome="reverted",
                    user_id=str(user_id),
                )
            except Exception:
                pass
    except Exception:
        pass
    return {"undone": undone, "errors": errors}


@router.get("/jobs/{job_id}/report.csv")
async def c2a_job_report_csv(
    job_id: str,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
):
    q = select(C2ASuggestion).where(
        C2ASuggestion.job_id == job_id, C2ASuggestion.status == "error"
    )
    rows = (await db.execute(q)).scalars().all()
    contact_ids = [r.contact_id for r in rows if r.contact_id]
    # Hydrate names/emails
    company = {}
    email = {}
    if contact_ids:
        ids = list(dict.fromkeys(contact_ids))[:200]
        ids_clause = ",".join(f"'{i}'" for i in ids)
        soql = f"SELECT Id, FirstName, LastName, Email FROM Contact WHERE Id IN ({ids_clause}) LIMIT {len(ids)}"
        try:
            res = await sf.soql(
                soql,
                plan_allowlist={
                    "Contact": ["Id", "FirstName", "LastName", "Email"],
                    "Account": ["Id", "Name", "Website"],
                },
            )
            for rec in res.get("records", []) or []:
                email[rec.get("Id")] = rec.get("Email")
                fn = (rec.get("FirstName") or "").strip()
                ln = (rec.get("LastName") or "").strip()
                company[rec.get("Id")] = (fn + " " + ln).strip()
        except Exception:
            pass

    def row_iter():
        yield "contact_id,name,email,previous_value,error\n"
        for r in rows:
            cid = r.contact_id or ""
            comp = (company.get(cid) or "").replace(",", " ")
            em = (email.get(cid) or "").replace(",", " ")
            prev = (r.previous_value or "").replace(",", " ")
            err = (
                (getattr(r, "error_message", "") or "")
                .replace("\n", " ")
                .replace(",", ";")
            )
            yield f"{cid},{comp},{em},{prev},{err}\n"

    return StreamingResponse(row_iter(), media_type="text/csv")


class HydrateBody(BaseModel):
    ids: List[str]


@router.post("/hydrate")
async def c2a_hydrate(
    body: HydrateBody,
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
):
    import re

    SFID = re.compile(r"^[A-Za-z0-9]{18}$")
    raw = list(dict.fromkeys(body.ids or []))[:1000]
    ids = [i for i in raw if isinstance(i, str) and SFID.fullmatch(i)]
    acc_ids = [i for i in ids if i.startswith("001")]
    con_ids = [i for i in ids if i.startswith("003")]
    out = {"accounts": [], "contacts": []}
    # Allow the hydrate step to request the exact fields it returns without tripping gateway allowlist checks.
    base_allowlist = {
        "objects": ["Account", "Contact"],
        "fields": {
            "Account": [
                "Id",
                "Name",
                "Website",
                "Phone",
                "Industry",
                "OwnerId",
                "BillingCity",
                "BillingState",
                "BillingCountry",
                "Domain__c",
                "Type",
            ],
            "Contact": [
                "Id",
                "Name",
                "FirstName",
                "LastName",
                "Email",
                "Phone",
                "MobilePhone",
                "Title",
                "Department",
                "MailingCity",
                "MailingState",
                "MailingCountry",
                "MailingPostalCode",
                "AccountId",
                "OwnerId",
            ],
        },
    }

    if acc_ids:
        n = min(len(acc_ids), 1000)
        clause = ",".join(f"'{i}'" for i in acc_ids[:n])
        q = (
            "SELECT Id, Name, Website, Phone, Industry "
            f"FROM Account WHERE Id IN ({clause}) LIMIT {n}"
        )
        res = await sf.soql(q, plan_allowlist=copy.deepcopy(base_allowlist))
        out["accounts"] = res.get("records", []) or []
    if con_ids:
        n = min(len(con_ids), 1000)
        clause = ",".join(f"'{i}'" for i in con_ids[:n])
        q = (
            "SELECT Id, Name, FirstName, LastName, Email, Phone, MobilePhone, Title "
            f"FROM Contact WHERE Id IN ({clause}) LIMIT {n}"
        )
        res = await sf.soql(q, plan_allowlist=copy.deepcopy(base_allowlist))
        out["contacts"] = res.get("records", []) or []
    return out


# ========== Safe Apply (approve-safe) ==========
@router.post("/safe-apply", dependencies=[Depends(require_admin_or_manager)])
async def c2a_safe_apply(
    min_score: float = 0.80,
    writeback_mode: str = "account",
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
):
    # Select pending suggestions over threshold (cap for safety)
    q = select(C2ASuggestion.id).where(
        C2ASuggestion.status == "pending",
        C2ASuggestion.score >= float(min_score),
        C2ASuggestion.score_gap >= 0.05,  # ensure no close conflicts
        C2ASuggestion.account_id != None,  # noqa: E711
    )
    ids = [str(r[0]) for r in (await db.execute(q)).all()][:2000]
    applied = 0
    errors = 0
    # Reuse existing apply implementation in batches of 100
    for i in range(0, len(ids), 100):
        chunk = ids[i : i + 100]
        # Tag pending suggestions as 'autofill' for reporting surfaces
        try:
            if chunk:
                await db.execute(
                    update(C2ASuggestion)
                    .where(C2ASuggestion.id.in_(chunk))
                    .values(explanation="autofill")
                )
                await db.flush()
        except Exception:
            pass
        res = await c2a_apply(chunk, db=db, sf=sf, writeback_mode=writeback_mode)  # type: ignore[arg-type]
        try:
            applied += int(res.get("applied", 0))
            errors += int(len(res.get("errors", []) or []))
        except Exception:
            pass
    return {"applied": applied, "errors": errors}
