import os
from datetime import datetime
from io import BytesIO
from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
import uvicorn

import db
import error_logging
import sync_engine
import script_runner
import script_audit
import contact_classifier


# ============================================================================
# APP MODE — Dry-Run global
# ============================================================================
# Cada request mutante lleva el header X-Dry-Run (1/0) desde el frontend
# (window.AppMode). Los endpoints que escriben en Bucéfalo deben llamar a
# is_dry_run_request(request) y, si True, NO llamar a GHL: devuelven una
# respuesta de simulación con shape compatible al live (mismo nombre de
# campos, totales razonables). El audit registra la acción como simulada.

def is_dry_run_request(request: Request) -> bool:
    """Devuelve True si el cliente pidió simulación. Lee X-Dry-Run; acepta
    también X-App-Mode=dryrun por compatibilidad. Default: False (live)."""
    if request is None:
        return False
    raw = (request.headers.get("X-Dry-Run") or "").strip().lower()
    if raw in ("1", "true", "yes", "on"):
        return True
    if raw in ("0", "false", "no", "off"):
        return False
    mode = (request.headers.get("X-App-Mode") or "").strip().lower()
    return mode == "dryrun"


def dry_run_response(action: str, details: dict | None = None) -> dict:
    """Shape estándar de respuesta dry-run para endpoints mutantes."""
    return {
        "ok": True,
        "dry_run": True,
        "simulated_action": action,
        "details": details or {},
        "message": "Modo simulación: ninguna acción se aplicó en Bucéfalo.",
    }

app = FastAPI(title="MP Manager - Go High Level Control Panel", version="1.0.0")

# Habilitar CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


async def build_request_context(request: Request, include_body=False):
    context = {
        "request_id": getattr(request.state, "request_id", None),
        "method": request.method,
        "path": request.url.path,
        "query_params": dict(request.query_params),
        "client": request.client.host if request.client else None,
    }
    if include_body:
        try:
            body = await request.body()
            context["body"] = body.decode("utf-8", errors="replace") if body else ""
        except Exception as exc:
            context["body_error"] = str(exc)
    return context


@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
    request.state.request_id = error_logging.new_error_id()
    response = await call_next(request)
    response.headers["X-Request-ID"] = request.state.request_id
    return response


@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    context = await build_request_context(request, include_body=True)
    context.update({"status_code": exc.status_code, "detail": exc.detail})
    error_id = error_logging.log_error("fastapi_http_exception", exc, context, error_id=context["request_id"])
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.detail, "error_id": error_id},
        headers={"X-Request-ID": error_id},
    )


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    context = await build_request_context(request, include_body=True)
    context["status_code"] = 500
    error_id = error_logging.log_error("fastapi_unhandled_exception", exc, context, error_id=context["request_id"])
    return JSONResponse(
        status_code=500,
        content={"detail": "Error interno del servidor", "error_id": error_id},
        headers={"X-Request-ID": error_id},
    )

# Asegurar directorios
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
static_dir = os.path.join(BASE_DIR, "static")
templates_dir = os.path.join(BASE_DIR, "templates")
os.makedirs(static_dir, exist_ok=True)
os.makedirs(templates_dir, exist_ok=True)

# Montar archivos estáticos
app.mount("/static", StaticFiles(directory=static_dir), name="static")
templates = Jinja2Templates(directory=templates_dir)

# Caché de tokens en memoria al arrancar
TOKENS_CACHE = {}

@app.on_event("startup")
def startup_event():
    print("=== INICIANDO SERVIDOR MP MANAGER ===")
    print("Inicializando base de datos SQLite...")
    db.init_db()
    
    print("Cargando y parseando catálogo de cuentas desde CSV...")
    try:
        accounts = sync_engine.parse_accounts_csv()
        db.save_accounts(accounts)
        
        # Almacenar tokens en memoria (nunca van a la DB por seguridad)
        global TOKENS_CACHE
        TOKENS_CACHE = {acc['location_id']: acc['token'] for acc in accounts}
        print(f"Catálogo cargado con éxito: {len(accounts)} cuentas registradas en memoria.")
    except Exception as e:
        error_id = error_logging.log_error("startup_accounts_csv_failed", e)
        print(f"ADVERTENCIA CRÍTICA: No se pudo cargar el CSV de cuentas al iniciar: {e} | error_id={error_id}")

# --- RUTAS DE NAVEGACIÓN ---

@app.get("/", response_class=HTMLResponse)
def read_root(request: Request):
    return templates.TemplateResponse(request, "index.html")

# --- ENDPOINTS DE LA API ---

@app.get("/api/branches")
def get_branches():
    """
    Retorna la lista de todas las sucursales con sus métricas locales cacheadas.
    """
    accounts = db.get_accounts()
    result = []
    for acc in accounts:
        metrics = db.get_account_metrics(acc['location_id'])
        result.append({
            **acc,
            "metrics": metrics
        })
    return result

@app.get("/api/metrics")
def get_global_metrics():
    """
    Retorna estadísticas globales consolidadas.
    """
    return db.get_global_metrics()

# --- PROXY DE DATOS CACHEADOS (GHL CACHE) ---

def _attach_vehicle_fields(records, location_id, object_key, alias_to_out_key):
    """Adjunta a cada registro los valores de sus custom fields de vehículo.

    Los IDs de custom fields varían por sucursal, así que resolvemos los nombres
    (Marca/Versión/Año/Vehículo) desde la tabla `object_schemas` cacheada en
    SQLite — sin pegarle a la API de GHL. El matching es tolerante a acentos,
    mayúsculas y puntuación vía `normalize_text` + `FIELD_ALIASES`.

    `alias_to_out_key`: dict {alias_FIELD_ALIASES: clave_de_salida}. Cada registro
    queda con esas claves pobladas (valor o None si la sucursal no tiene el campo
    o el contacto no lo trae).
    """
    import json as _json
    from scripts.common import FIELD_ALIASES, normalize_text

    schema_rows = db.get_object_schemas(location_id, object_key)
    alias_to_ids = {alias: set() for alias in alias_to_out_key}
    for alias in alias_to_out_key:
        targets = {normalize_text(name) for name in FIELD_ALIASES.get(alias, [alias])}
        for row in schema_rows:
            if normalize_text(row.get("field_name") or "") in targets:
                fid = row.get("field_id")
                if fid:
                    alias_to_ids[alias].add(fid)

    for rec in records:
        cfs = rec.get("custom_fields")
        if cfs is None:
            cfs = rec.get("custom_fields_json")
        if isinstance(cfs, str):
            try:
                cfs = _json.loads(cfs) if cfs else []
            except Exception:
                cfs = []
        values = {}
        for field in cfs or []:
            fid = field.get("id") or field.get("fieldId")
            if not fid:
                continue
            val = field.get("value")
            if val is None:
                val = field.get("fieldValue")
            if val is None:
                val = field.get("fieldValueString")
            values[fid] = val
        for alias, out_key in alias_to_out_key.items():
            resolved = None
            for fid in alias_to_ids.get(alias, ()):
                v = values.get(fid)
                if v is not None and str(v).strip() != "":
                    resolved = v
                    break
            rec[out_key] = resolved


@app.get("/api/contacts/{location_id}")
def get_contacts(location_id: str, q: str = None, page: int = 1, limit: int = 100, without_opp: bool = False):
    """
    Devuelve contactos cacheados de una sucursal con marca `is_test` para
    cada uno (usa contact_classifier). Los contactos detectados como test/
    prueba/E3 quedan ordenados al inicio, despues el resto por date_added
    DESC. La paginacion se aplica sobre el set ya ordenado.

    Nota: para garantizar que TODOS los test queden arriba (no solo los de la
    pagina actual), traemos todos los contactos filtrados de la sucursal y
    paginamos en Python. Esto es razonable para los volumenes actuales (cientos
    por sucursal). Si alguna sucursal crece a miles, optimizar con un flag en
    SQL o un cache.
    """
    if page < 1:
        page = 1
    if limit < 1:
        limit = 100

    # Traemos todos los contactos que matchean el filtro (sin limit en SQL).
    # Usamos un limit alto en lugar de None para no romper la firma actual.
    all_contacts = db.get_contacts(
        location_id,
        search_query=q,
        limit=10_000_000,
        offset=0,
        without_opp=without_opp,
    )

    # Anotar is_test / test_reasons
    for c in all_contacts:
        contact_classifier.annotate_contact(c)

    # Adjuntar info de vehículo (custom fields del contacto) para mostrarla en la UI.
    _attach_vehicle_fields(all_contacts, location_id, "contact", {
        "marca_vehiculo": "marca_vehiculo",
        "version_vehiculo": "version_vehiculo",
        "ano_vehiculo": "ano_vehiculo",
    })

    # Orden: is_test primero, luego por date_added DESC (None al final)
    def _sort_key(c):
        date_added = c.get("date_added") or ""
        return (0 if c.get("is_test") else 1, _negate_date(date_added))

    all_contacts.sort(key=_sort_key)

    total = len(all_contacts)
    test_count = sum(1 for c in all_contacts if c.get("is_test"))

    start = (page - 1) * limit
    end = start + limit
    page_contacts = all_contacts[start:end]

    return {
        "location_id": location_id,
        "page": page,
        "limit": limit,
        "total": total,
        "test_count": test_count,
        "without_opp": without_opp,
        "contacts": page_contacts,
    }


def _negate_date(date_str):
    """
    Helper para invertir el ordenamiento de fechas ISO en un sort ascendente.
    Como las fechas son strings comparables, restamos al maximo posible.
    """
    # Truco simple: invertir con un sentinel grande para que sort asc => DESC efectivo
    return tuple(-ord(c) for c in date_str[:24])


@app.post("/api/contacts/{location_id}/{contact_id}/opportunity")
def create_opportunity_for_contact(location_id: str, contact_id: str, request: Request):
    """
    Crea una oportunidad para un contacto sin oportunidad usando el script
    `create_opportunities_for_contacts_without_any.create_for_single_contact`.

    Comportamiento fijo (modo estricto, sin parámetros configurables):
      - status: open
      - mapea sucursal, tienda, fuente_prospecto, canal_origen (requeridos) y
        vehiculo/marca/version/año (opcionales) del contacto a la oportunidad
      - aborta si falta cualquiera de los 4 requeridos
      - registra cambio en script_audit para rollback desde el dashboard

    Respeta el toggle global vía header X-Dry-Run: en simulación devuelve un
    preview sin invocar GHL.
    """
    import uuid
    from scripts import create_opportunities_for_contacts_without_any as creator

    # Modo simulación: chequear pre-condiciones razonables sin escribir.
    if is_dry_run_request(request):
        contact = db.get_contact_by_id(location_id, contact_id)
        if not contact:
            return dry_run_response("create_opportunity_for_contact", {
                "ok": False,
                "warning": "Contacto no está en caché local; en live se intentaría descargar de Bucéfalo.",
                "contact_id": contact_id,
                "location_id": location_id,
            })
        has_opp = db.contact_has_opportunity(location_id, contact_id) if hasattr(db, "contact_has_opportunity") else False
        return dry_run_response("create_opportunity_for_contact", {
            "contact_id": contact_id,
            "location_id": location_id,
            "contact_name": " ".join(filter(None, [contact.get("first_name"), contact.get("last_name")])).strip(),
            "already_has_opportunity": has_opp,
            "would_create": not has_opp,
        })

    run_id = f"co1-{uuid.uuid4().hex[:12]}"
    try:
        script_audit.init_audit_db()
        script_audit.create_run(
            run_id,
            "create_opportunities_for_contacts_without_any.py",
            arguments=f"--contact-id {contact_id} --map-custom-fields --strict-fields --apply",
            locations=[location_id],
            execution_mode="sequential",
        )
    except Exception as exc:
        error_logging.log_error("create_opp_for_contact_run_init_failed", exc, {
            "location_id": location_id,
            "contact_id": contact_id,
        })
        run_id = None

    try:
        result = creator.create_for_single_contact(
            location_id,
            contact_id,
            map_custom_fields=True,
            strict_fields=True,
            status="open",
            run_id=run_id,
        )
    except Exception as exc:
        error_id = error_logging.log_error("create_opp_for_contact_failed", exc, {
            "location_id": location_id,
            "contact_id": contact_id,
            "run_id": run_id,
        })
        if run_id:
            try:
                script_audit.update_run_status(run_id, "failed", str(exc))
            except Exception:
                pass
        raise HTTPException(status_code=500, detail=f"Error inesperado: {exc} (error_id={error_id})")

    if run_id:
        try:
            script_audit.update_run_status(
                run_id,
                "success" if result.get("ok") else "failed",
                None if result.get("ok") else result.get("error"),
            )
        except Exception:
            pass
        result["run_id"] = run_id

    if not result.get("ok"):
        # 409 para errores de negocio (faltan campos, pipeline inválido, contacto ya tiene opp);
        # el frontend muestra el mensaje al usuario.
        raise HTTPException(status_code=409, detail=result)

    return result

@app.post("/api/contacts/{location_id}/bulk-create-opportunities")
def bulk_create_opportunities(location_id: str, request: Request):
    """
    Crea oportunidades para TODOS los contactos sin oportunidad de la sucursal.

    Respeta el toggle global vía header X-Dry-Run: en simulación devuelve el
    listado de candidatos y cuántos pasarían las validaciones, sin tocar GHL.
    Itera cada contacto y delega en `create_for_single_contact`. Todos los
    cambios quedan registrados bajo un único `run_id` en script_audit para
    auditoría/rollback desde el dashboard.

    Sincrono: para sucursales con cientos de contactos puede tardar varios
    minutos (rate limit GHL ~110ms por request por token). El frontend muestra
    spinner durante la operación y al final un resumen con OK/saltados/fallidos.

    Retorna:
      {
        "ok": bool,
        "run_id": str,
        "totals": {"processed": N, "created": N, "skipped": N, "failed": N},
        "details": [ {contact_id, name, ok, error_code?, error?, opportunity_id?} ... ],
      }
    """
    import uuid
    from scripts import create_opportunities_for_contacts_without_any as creator

    # 1. Traer contactos sin oportunidad de la sucursal (todos, sin paginar).
    candidates = db.get_contacts(
        location_id,
        search_query=None,
        limit=10_000_000,
        offset=0,
        without_opp=True,
    )
    if not candidates:
        return {
            "ok": True,
            "run_id": None,
            "totals": {"processed": 0, "created": 0, "skipped": 0, "failed": 0},
            "details": [],
            "message": "No hay contactos sin oportunidad en esta sucursal.",
        }

    # Modo simulación: enumerar candidatos sin escribir.
    if is_dry_run_request(request):
        preview = []
        for c in candidates[:50]:
            preview.append({
                "contact_id": c.get("id"),
                "name": " ".join(filter(None, [c.get("first_name"), c.get("last_name")])).strip()
                        or c.get("email") or c.get("id"),
            })
        return {
            "ok": True,
            "dry_run": True,
            "run_id": None,
            "totals": {
                "processed": len(candidates),
                "created": 0,
                "skipped": 0,
                "failed": 0,
                "would_attempt": len(candidates),
            },
            "details": preview,
            "message": f"Modo simulación: se intentarían crear {len(candidates)} oportunidades en Bucéfalo (live cambia a este endpoint con X-Dry-Run: 0).",
        }

    # 2. Crear run_id de auditoría.
    run_id = f"cobulk-{uuid.uuid4().hex[:12]}"
    try:
        script_audit.init_audit_db()
        script_audit.create_run(
            run_id,
            "create_opportunities_for_contacts_without_any.py",
            arguments=f"--bulk-from-dashboard --location {location_id} --map-custom-fields --strict-fields --apply",
            locations=[location_id],
            execution_mode="sequential",
        )
    except Exception as exc:
        error_logging.log_error("bulk_create_opps_run_init_failed", exc, {
            "location_id": location_id,
        })
        run_id = None

    # 3. Iterar y crear.
    created = skipped = failed = 0
    details = []
    for c in candidates:
        contact_id = c.get("id")
        name = " ".join(filter(None, [c.get("first_name"), c.get("last_name")])).strip() or c.get("email") or contact_id
        try:
            result = creator.create_for_single_contact(
                location_id,
                contact_id,
                map_custom_fields=True,
                strict_fields=True,
                status="open",
                run_id=run_id,
            )
        except Exception as exc:
            failed += 1
            error_id = error_logging.log_error("bulk_create_opps_item_failed", exc, {
                "location_id": location_id,
                "contact_id": contact_id,
                "run_id": run_id,
            })
            details.append({
                "contact_id": contact_id,
                "name": name,
                "ok": False,
                "error": f"Excepción inesperada: {exc}",
                "error_code": "unexpected_exception",
                "error_id": error_id,
            })
            continue

        if result.get("ok"):
            created += 1
            details.append({
                "contact_id": contact_id,
                "name": name,
                "ok": True,
                "opportunity_id": result.get("opportunity_id"),
            })
        else:
            # Distinguimos "saltado por regla de negocio" vs "fallido inesperado".
            # Estos códigos vienen del propio create_for_single_contact.
            code = result.get("error_code") or ""
            if code in ("contact_has_opportunity", "missing_custom_fields", "pipeline_invalid"):
                skipped += 1
            else:
                failed += 1
            details.append({
                "contact_id": contact_id,
                "name": name,
                "ok": False,
                "error": result.get("error"),
                "error_code": code,
            })

    # 4. Cerrar run de auditoría.
    if run_id:
        status = "success" if failed == 0 else "failed"
        try:
            script_audit.update_run_status(
                run_id,
                status,
                None if status == "success" else f"{failed} fallos de {len(candidates)}",
            )
        except Exception:
            pass

    return {
        "ok": failed == 0,
        "run_id": run_id,
        "totals": {
            "processed": len(candidates),
            "created": created,
            "skipped": skipped,
            "failed": failed,
        },
        "details": details,
    }


@app.post("/api/pipelines/{location_id}/dedupe")
async def dedupe_pipelines_for_branch(location_id: str, request: Request):
    """
    Migra oportunidades del pipeline más antiguo al más reciente (por dateUpdated)
    para una sucursal. Reutiliza la lógica probada de scripts/dedupe_branch_pipelines.py.

    Modos:
      - dry-run (header X-Dry-Run: 1): devuelve el plan con origen/destino, mapeo
        de etapas y cantidad de oportunidades a mover; no escribe en Bucéfalo.
      - live (header X-Dry-Run: 0): aplica las migraciones y registra cada cambio
        en script_audit bajo un único run_id reversible (PUT /opportunities/{id}
        cambiando pipelineId + pipelineStageId).

    Body opcional (JSON):
      - from_pipeline, to_pipeline: IDs explícitos cuando los nombres normalizados
        no coinciden (uso "caso excepcional", como en Atizapán "Standar"/"Standar1").

    Restricción de seguridad: este endpoint NO actúa sobre la cuenta principal de
    Marca (location_id `GbKkBpCmKu2QmloKFHy3`).
    """
    import sys as _sys
    import uuid
    scripts_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
    if scripts_dir not in _sys.path:
        _sys.path.insert(0, scripts_dir)
    import dedupe_branch_pipelines as dedupe_mod  # noqa: E402

    # Validaciones tempranas.
    BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
    if location_id == BRAND_LOCATION_ID:
        raise HTTPException(status_code=400, detail="Por seguridad, el dedupe no se ejecuta sobre la cuenta de Marca principal.")

    accounts = sync_engine.parse_accounts_csv()
    account = next((a for a in accounts if a["location_id"] == location_id), None)
    if not account:
        raise HTTPException(status_code=404, detail=f"Sucursal {location_id} no existe en el CSV de tokens.")

    body = {}
    try:
        body = await request.json()
    except Exception:
        body = {}
    from_pipeline = (body or {}).get("from_pipeline")
    to_pipeline = (body or {}).get("to_pipeline")
    explicit_pair = bool(from_pipeline and to_pipeline)

    # 1. Cargar pipelines live de Bucéfalo.
    try:
        raw_pipelines = sync_engine.ghl_client.get_pipelines(account["token"], location_id)
    except Exception as exc:
        error_id = error_logging.log_error("dedupe_pipelines_fetch_failed", exc, {"location_id": location_id})
        raise HTTPException(status_code=502, detail=f"Bucéfalo rechazó la lectura de pipelines: {exc} (error_id={error_id})")

    if not raw_pipelines or len(raw_pipelines) < 2:
        return {
            "ok": True,
            "dry_run": is_dry_run_request(request),
            "totals": {"plans": 0, "opportunities": 0},
            "plans": [],
            "message": "No hay pipelines duplicados para deduplicar (la sucursal tiene 0 o 1 pipeline).",
        }

    pipelines = [dedupe_mod.normalize_pipeline(p) for p in raw_pipelines]

    # 2. Construir plan (auto-detect por nombre, o explícito por IDs).
    try:
        if explicit_pair:
            plans = dedupe_mod.build_plan_explicit(account, pipelines, from_pipeline, to_pipeline, True, sync_engine.ghl_client)
        else:
            plans = dedupe_mod.build_plan(account, pipelines, True, sync_engine.ghl_client)
    except SystemExit as exc:
        # build_plan_explicit lanza SystemExit con mensaje cuando los IDs no existen.
        raise HTTPException(status_code=400, detail=str(exc))

    # 3. Serializar plan para la respuesta (útil en dry-run y como confirmación pre-apply).
    plans_payload = []
    total_opps = 0
    total_merge_obsolete = 0
    total_merge_official = 0
    total_plain_move = 0
    for plan in plans:
        opps = plan.get("opportunities") or []
        total_opps += len(opps)
        # Contar tipos de operación a partir del merge_analysis precomputado.
        analysis = plan.get("merge_analysis") or []
        plan_merge_obsolete = sum(1 for a in analysis if a.get("is_duplicate") and a.get("winner") == "obsolete")
        plan_merge_official = sum(1 for a in analysis if a.get("is_duplicate") and a.get("winner") == "official")
        plan_plain_move = sum(1 for a in analysis if not a.get("is_duplicate"))
        total_merge_obsolete += plan_merge_obsolete
        total_merge_official += plan_merge_official
        total_plain_move += plan_plain_move
        plans_payload.append({
            "obsolete": {
                "id": plan["obsolete"]["id"],
                "name": plan["obsolete"]["name"],
                "date_added": plan["obsolete"].get("date_added"),
                "date_updated": plan["obsolete"].get("date_updated"),
                "stages_count": len(plan["obsolete"].get("stages") or []),
            },
            "official": {
                "id": plan["official"]["id"],
                "name": plan["official"]["name"],
                "date_added": plan["official"].get("date_added"),
                "date_updated": plan["official"].get("date_updated"),
                "stages_count": len(plan["official"].get("stages") or []),
            },
            "mapping_kind": plan.get("mapping_kind"),
            "mapping": [
                {"from_stage": old["name"], "to_stage": new["name"]}
                for old, new in (plan.get("mapping") or [])
            ] if plan.get("mapping") else None,
            "opportunities_count": len(opps),
            # Breakdown del merge: cuántas pasan por cada flujo.
            "plain_move_count": plan_plain_move,
            "merge_obsolete_won_count": plan_merge_obsolete,
            "merge_official_won_count": plan_merge_official,
        })

    # 4. Dry-run: devolver el plan sin escribir.
    if is_dry_run_request(request):
        merge_total = total_merge_obsolete + total_merge_official
        merge_summary = (
            f" De ellas, {total_plain_move} se moverían directamente, "
            f"{merge_total} resolverían un duplicado vía merge "
            f"({total_merge_obsolete} con la obsoleta ganando + {total_merge_official} con la oficial ganando)."
        ) if merge_total > 0 else ""
        return {
            "ok": True,
            "dry_run": True,
            "totals": {
                "plans": len(plans_payload),
                "opportunities": total_opps,
                "plain_move": total_plain_move,
                "merge_obsolete_won": total_merge_obsolete,
                "merge_official_won": total_merge_official,
            },
            "plans": plans_payload,
            "message": ("Modo simulación: no se migró nada. "
                        f"En live se procesarían {total_opps} oportunidad(es) entre {len(plans_payload)} plan(es)."
                        + merge_summary),
        }

    if not plans:
        return {
            "ok": True,
            "totals": {"plans": 0, "opportunities": 0},
            "plans": [],
            "message": "No se detectaron pipelines duplicados con nombres normalizados coincidentes.",
        }

    # 5. Live apply.
    run_id = f"pipededupe-{uuid.uuid4().hex[:12]}"
    try:
        script_audit.init_audit_db()
        script_audit.create_run(
            run_id,
            "dedupe_branch_pipelines.py",
            arguments=f"--location {location_id} --live --apply --from-dashboard",
            locations=[location_id],
            execution_mode="sequential",
        )
    except Exception as exc:
        error_logging.log_error("dedupe_pipelines_run_init_failed", exc, {"location_id": location_id})
        run_id = None

    total_moved = total_skipped = total_duplicates = total_failed = 0
    total_merged_obsolete_won = total_merged_official_won = 0
    apply_results = []
    for plan in plans:
        if plan.get("mapping") is None:
            apply_results.append({
                "obsolete_id": plan["obsolete"]["id"],
                "official_id": plan["official"]["id"],
                "skipped": "mapping_impossible",
            })
            continue
        res = dedupe_mod.apply_plan(account, plan, run_id, None, sync_engine.ghl_client)
        moved = res["moved"]
        skipped = res["skipped"]
        duplicates = res["duplicates"]
        failed = res["failed"]
        merged_o = res["merged_obsolete_won"]
        merged_f = res["merged_official_won"]
        dup_records = res["duplicate_records"]
        total_moved += moved
        total_skipped += skipped
        total_duplicates += duplicates
        total_failed += failed
        total_merged_obsolete_won += merged_o
        total_merged_official_won += merged_f
        apply_results.append({
            "obsolete_id": plan["obsolete"]["id"],
            "official_id": plan["official"]["id"],
            "moved": moved,
            "skipped": skipped,
            "duplicates": duplicates,
            "merged_obsolete_won": merged_o,
            "merged_official_won": merged_f,
            "failed": failed,
            "duplicate_records": dup_records,
        })

    if run_id:
        try:
            script_audit.update_run_status(
                run_id,
                "success" if total_failed == 0 else "failed",
                None if total_failed == 0 else f"{total_failed} fallos",
            )
        except Exception:
            pass

    return {
        "ok": total_failed == 0,
        "dry_run": False,
        "run_id": run_id,
        "totals": {
            "plans": len(plans_payload),
            "opportunities": total_opps,
            "moved": total_moved,
            "skipped": total_skipped,
            "duplicates_in_official": total_duplicates,
            "merged_obsolete_won": total_merged_obsolete_won,
            "merged_official_won": total_merged_official_won,
            "failed": total_failed,
        },
        "plans": plans_payload,
        "results": apply_results,
        "message": (f"Migración completada: {total_moved} movidas, "
                    f"{total_merged_obsolete_won} merge(obsoleto gana), "
                    f"{total_merged_official_won} merge(oficial gana), "
                    f"{total_skipped} saltadas, {total_duplicates} DUP no resueltos, "
                    f"{total_failed} fallidas."),
    }


@app.get("/api/pipelines/{location_id}")
def get_pipelines(location_id: str):
    pipelines = db.get_pipelines(location_id)

    def is_synthetic_pipeline(pipeline):
        name = pipeline.get("name") or ""
        stages = pipeline.get("stages") or []
        if name.startswith("Pipeline Sint"):
            return True
        return any((stage.get("name") or "").startswith("Etapa ") and (stage.get("name") or "").endswith("...") for stage in stages)

    if TOKENS_CACHE.get(location_id) and (not pipelines or any(is_synthetic_pipeline(p) for p in pipelines)):
        live_pipelines = sync_engine.ghl_client.get_pipelines(TOKENS_CACHE[location_id], location_id)
        if live_pipelines:
            db.save_pipelines(location_id, live_pipelines)
            pipelines = db.get_pipelines(location_id)

    return {"location_id": location_id, "pipelines": pipelines}

@app.get("/api/location/{location_id}")
def get_location_details(location_id: str):
    """
    Obtiene la información detallada de una sucursal,
    intentando obtenerla de GHL o recurriendo a los datos locales.
    """
    acc = db.get_account(location_id)
    if not acc:
        raise HTTPException(status_code=404, detail="Cuenta no encontrada")
    
    token = TOKENS_CACHE.get(location_id)
    ghl_data = None
    if token:
        try:
            ghl_data = sync_engine.ghl_client.get_location(token, location_id)
        except Exception as e:
            error_logging.log_error("api_get_location_ghl_failed", e, {"location_id": location_id})
            
    return {
        "local_data": acc,
        "ghl_data": ghl_data
    }

@app.get("/api/opportunities/{location_id}")
def get_opportunities(location_id: str, pipeline_id: str = None):
    opps = db.get_opportunities(location_id, pipeline_id=pipeline_id)
    # En oportunidades solo existe el campo combinado "Vehículo".
    _attach_vehicle_fields(opps, location_id, "opportunity", {"vehiculo": "vehiculo"})
    return {"location_id": location_id, "opportunities": opps}

# --- COMPARATIVA MARCA vs SUCURSALES ---

@app.get("/api/comparativa/marca-vs-sucursales")
def get_comparativa_marca_vs_sucursales(limit_missing: int = 500):
    """
    Devuelve la comparativa de conteos y matching entre la cuenta de Marca y la
    suma de todas las sucursales no-demo. Los listados de ausentes se recortan
    a `limit_missing` items por defecto (la UI puede pedir mas via query param).
    """
    from scripts import audit_brand_vs_branches_totals as audit
    try:
        # limit_missing<=0 → sin limite
        cap = None if (limit_missing is None or limit_missing <= 0) else limit_missing
        return audit.run_audit(limit_missing=cap)
    except FileNotFoundError as e:
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        raise HTTPException(status_code=409, detail=str(e))


@app.get("/api/comparativa/marca-vs-sucursales/export")
def export_comparativa(bucket: str):
    """
    Exporta uno de los listados de la comparativa a CSV.

    bucket admite:
      - missing_in_brand
      - missing_in_assigned_branch
      - brand_without_tienda
      - brand_unknown_tienda
      - brand_not_in_any_branch
      - missing_opps_in_brand
      - brand_duplicate_link_opps
      - per_branch
    """
    from scripts import audit_brand_vs_branches_totals as audit
    import csv as _csv
    from io import StringIO

    bucket_map = {
        "missing_in_brand": ("contacts_in_branch_not_in_brand", "missing"),
        "missing_in_assigned_branch": ("contacts_in_brand_not_in_assigned_branch", "missing"),
        "present_in_other_branch_not_assigned": ("contacts_in_brand_present_in_other_branch_not_assigned", "missing"),
        "probable_duplicate_in_brand": ("contacts_in_brand_probable_duplicate", "missing"),
        "brand_without_tienda": ("contacts_in_brand_without_tienda", "missing"),
        "brand_unknown_tienda": ("contacts_in_brand_with_unknown_tienda", "missing"),
        "brand_not_in_any_branch": ("contacts_in_brand_not_in_any_branch", "missing"),
        "missing_opps_in_brand": ("opportunities_in_branch_not_in_brand", "missing"),
        "opps_missing_id_field": ("opportunities_missing_id_field", "missing"),
        "brand_duplicate_link_opps": ("opportunities_in_brand_duplicate_link", "missing"),
        "contacts_missing_id_field": ("contacts_missing_id_field", "missing"),
        "intra_brand_duplicates": ("intra_brand_duplicates", "missing"),
        "per_branch": ("per_branch", "per_branch"),
    }
    if bucket not in bucket_map:
        raise HTTPException(status_code=400, detail=f"bucket invalido. Validos: {list(bucket_map.keys())}")

    try:
        data = audit.run_audit(limit_missing=None)
    except FileNotFoundError as e:
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        raise HTTPException(status_code=409, detail=str(e))

    key, root = bucket_map[bucket]
    if root == "missing":
        rows = data["missing"][key]["items"]
    else:
        rows = data["per_branch"]

    buf = StringIO()
    if rows:
        # Recolectar union de keys para ser tolerantes con items heterogeneos.
        all_keys = []
        seen = set()
        for r in rows:
            for k in r.keys():
                if k not in seen:
                    seen.add(k)
                    all_keys.append(k)
        writer = _csv.DictWriter(buf, fieldnames=all_keys)
        writer.writeheader()
        for r in rows:
            writer.writerow({k: r.get(k, "") for k in all_keys})
    else:
        buf.write("(sin filas)\n")

    filename = f"comparativa_{bucket}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
    return StreamingResponse(
        iter([buf.getvalue()]),
        media_type="text/csv; charset=utf-8",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


@app.post("/api/comparativa/sync-missing-opps")
async def sync_missing_opps(request: Request):
    """
    Sincroniza al CRM de Marca las oportunidades del bucket
    "opportunities_in_branch_not_in_brand". Default dry-run.

    Body JSON:
      {
        "dry_run": true|false,     # default true
        "opp_ids": ["id1", ...],   # opcional, default todas las del bucket
        "yes": true                # confirma cuando dry_run=false (igual que --yes CLI)
      }
    """
    from scripts import sync_missing_opps_to_brand as sync_script
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = bool(body.get("dry_run", True))
    opp_ids = body.get("opp_ids") or None
    confirmed = bool(body.get("yes", False))

    if not dry_run and not confirmed:
        raise HTTPException(
            status_code=400,
            detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
        )

    # Crear run_id en script_audit cuando se aplica, para tener rollback.
    run_id = None
    if not dry_run:
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"smt-{uuid.uuid4().hex[:12]}"
            script_audit.create_run(
                run_id,
                sync_script.SCRIPT_NAME,
                arguments=f"--apply opp_ids={opp_ids or 'all'}",
                locations=[sync_script.BRAND_LOCATION_ID],
                execution_mode="sequential",
            )
        except Exception as e:
            error_logging.log_error("sync_missing_opps_start_run_failed", e)
            run_id = None

    try:
        result = sync_script.run_sync(
            opp_ids=opp_ids,
            dry_run=dry_run,
            log=lambda *_a, **_kw: None,  # silencioso en API; la UI usa el JSON resultante
            run_id=run_id,
        )
    except FileNotFoundError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))

    if run_id:
        try:
            status = "failed" if result["summary"]["errors"] > 0 and (result["summary"]["opps_created"] + result["summary"]["opps_updated"]) == 0 else "completed"
            script_audit.update_run_status(run_id, status)
        except Exception:
            pass
        result["run_id"] = run_id

    return result


@app.post("/api/comparativa/cleanup-duplicate-opps")
async def cleanup_duplicate_opps(request: Request):
    """
    Elimina las replicas duplicadas de opps en Marca del bucket
    "opportunities_in_brand_duplicate_link" (mismo 'ID Oportunidad Sucursal').
    Conserva la canonica (jerarquia: valor -> status -> mas antigua) y borra las
    sobrantes. Default dry-run. Reversible por run_id (snapshot + script_audit).

    Body JSON:
      {
        "dry_run": true|false,     # default true
        "only_link": "<id>",       # opcional, limita a un cluster
        "yes": true                # confirma cuando dry_run=false
      }
    """
    from scripts import cleanup_brand_duplicate_replica_opps as cleanup_script
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = bool(body.get("dry_run", True))
    only_link = body.get("only_link") or None
    confirmed = bool(body.get("yes", False))

    if not dry_run and not confirmed:
        raise HTTPException(
            status_code=400,
            detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
        )

    run_id = None
    if not dry_run:
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"cdo-{uuid.uuid4().hex[:12]}"
        except Exception as e:
            error_logging.log_error("cleanup_duplicate_opps_start_run_failed", e)
            run_id = None

    try:
        result = cleanup_script.run(
            apply=not dry_run,
            run_id=run_id,
            only_link=only_link,
            log=lambda *_a, **_kw: None,  # silencioso en API; la UI usa el JSON
        )
    except FileNotFoundError as e:
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        raise HTTPException(status_code=409, detail=str(e))

    # Reshape para el modal generico del dashboard: items[] planos + summary.candidates.
    plans = result.get("plans", [])
    ok_plans = [p for p in plans if p.get("status") == "ok"]
    result["items"] = [
        {
            "status": "deleted" if not dry_run else "plan",
            "link_value": p.get("link_value"),
            "name": p.get("name"),
            "keep": p.get("keep"),
            "delete": p.get("delete", []),
        }
        for p in ok_plans
    ]
    summary = result.setdefault("summary", {})
    summary["candidates"] = summary.get("clusters", 0)

    if run_id:
        result["run_id"] = run_id

    return result


@app.post("/api/comparativa/fill-opp-id-sucursal")
async def fill_opp_id_sucursal(request: Request):
    """
    Llena el custom field 'ID Oportunidad Sucursal' de cada opp con su propio id
    nativo. Acciona el bucket "opportunities_missing_id_field" SOLO en sucursales
    (Marca queda informativa, no se llena: el filtro de location_ids/opp_ids del
    body se sanea contra el location_id de Marca).

    Body JSON:
      {
        "dry_run": true|false,         # default true
        "opp_ids": ["id1", ...],       # opcional, default todas las del bucket
        "location_ids": ["loc1", ...], # opcional, default todas las sucursales
        "yes": true                    # confirma cuando dry_run=false
      }
    """
    from scripts import fill_opp_id_oportunidad_sucursal as fill_script
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = bool(body.get("dry_run", True))
    opp_ids = body.get("opp_ids") or None
    location_ids = body.get("location_ids") or None
    confirmed = bool(body.get("yes", False))

    if not dry_run and not confirmed:
        raise HTTPException(
            status_code=400,
            detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
        )

    # Defensa: nunca tocar Marca. Si llega como location_id explícito, se quita.
    BRAND = fill_script.BRAND_LOCATION_ID
    if location_ids:
        location_ids = [l for l in location_ids if l != BRAND]

    run_id = None
    if not dry_run:
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"fill-oppid-{uuid.uuid4().hex[:12]}"
        except Exception as e:
            error_logging.log_error("fill_opp_id_start_run_failed", e)
            run_id = None

    try:
        result = fill_script.run_fill(
            location_ids=location_ids,
            opp_ids=opp_ids,
            dry_run=dry_run,
            run_id=run_id,
            log=lambda *_a, **_kw: None,  # silencioso en API
        )
    except FileNotFoundError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))

    if run_id:
        try:
            status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["set"] == 0 else "completed"
            script_audit.update_run_status(run_id, status)
        except Exception:
            pass
        result["run_id"] = run_id

    return result


@app.post("/api/comparativa/match-brand-opp-id-sucursal")
async def match_brand_opp_id_sucursal(request: Request):
    """
    Matchea opportunities de Marca contra sus contrapartes en sucursales y
    rellena el custom field "ID Oportunidad Sucursal" en Marca con el id de la
    opp de sucursal. Acciona las filas con badge MARCA del bucket
    "opportunities_missing_id_field" (donde el botón normal de llenado no
    aplica porque el campo no se llena con el id propio sino con el id de la
    opp sucursal de origen).

    Filtro defensivo INVERSO al de /fill-opp-id-sucursal: solo opera sobre
    BRAND_LOCATION_ID. Si el body trae opp_ids, se sanean para excluir cualquier
    id que no pertenezca a una opp de Marca.

    Body JSON:
      {
        "dry_run": true|false,    # default true
        "opp_ids": ["id1", ...],  # opcional, default todas las opps Marca con CF vacio/invalido
        "yes": true               # confirma cuando dry_run=false
      }
    """
    from scripts import backfill_opp_sucursal_link as match_script
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = bool(body.get("dry_run", True))
    opp_ids = body.get("opp_ids") or None
    confirmed = bool(body.get("yes", False))

    if not dry_run and not confirmed:
        raise HTTPException(
            status_code=400,
            detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
        )

    # Defensa: filtrar opp_ids para garantizar que sean opps de Marca.
    BRAND = match_script.BRAND_LOCATION_ID
    if opp_ids:
        conn = db.get_db_connection()
        try:
            placeholders = ",".join("?" for _ in opp_ids)
            rows = conn.execute(
                f"SELECT id FROM opportunities "
                f"WHERE id IN ({placeholders}) AND location_id = ?",
                (*opp_ids, BRAND),
            ).fetchall()
            opp_ids = [r["id"] for r in rows]
        finally:
            conn.close()
        if not opp_ids:
            raise HTTPException(
                status_code=400,
                detail="Ninguno de los opp_ids enviados pertenece a Marca.",
            )

    run_id = None
    if not dry_run:
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"match-brand-oppid-{uuid.uuid4().hex[:12]}"
        except Exception as e:
            error_logging.log_error("match_brand_opp_id_start_run_failed", e)
            run_id = None

    try:
        result = match_script.run_match(
            opp_ids=opp_ids,
            dry_run=dry_run,
            run_id=run_id,
            log=lambda *_a, **_kw: None,  # silencioso en API
        )
    except FileNotFoundError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))

    if run_id:
        try:
            applied = result["summary"].get("applied", 0)
            errors = result["summary"].get("errors", 0)
            status = "failed" if errors > 0 and applied == 0 else "completed"
            script_audit.update_run_status(run_id, status)
        except Exception:
            pass
        result["run_id"] = run_id

    return result


@app.post("/api/comparativa/fill-contact-id-sucursal")
async def fill_contact_id_sucursal(request: Request):
    """
    Llena el custom field 'ID Contacto Sucursal' de cada contacto con su propio
    id nativo. Acciona el bucket "contacts_missing_id_field" SOLO en sucursales
    (Marca queda informativa).

    Body JSON:
      {
        "dry_run": true|false,         # default true
        "contact_ids": ["id1", ...],   # opcional, default todos los del bucket
        "location_ids": ["loc1", ...], # opcional, default todas las sucursales
        "yes": true                    # confirma cuando dry_run=false
      }
    """
    from scripts import fill_contact_id_sucursal as fill_script
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = bool(body.get("dry_run", True))
    contact_ids = body.get("contact_ids") or None
    location_ids = body.get("location_ids") or None
    confirmed = bool(body.get("yes", False))

    if not dry_run and not confirmed:
        raise HTTPException(
            status_code=400,
            detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
        )

    BRAND = fill_script.BRAND_LOCATION_ID
    if location_ids:
        location_ids = [l for l in location_ids if l != BRAND]

    run_id = None
    if not dry_run:
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"fill-contactid-{uuid.uuid4().hex[:12]}"
        except Exception as e:
            error_logging.log_error("fill_contact_id_start_run_failed", e)
            run_id = None

    try:
        result = fill_script.run_fill(
            location_ids=location_ids,
            contact_ids=contact_ids,
            dry_run=dry_run,
            run_id=run_id,
            log=lambda *_a, **_kw: None,
        )
    except FileNotFoundError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))

    if run_id:
        try:
            status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["set"] == 0 else "completed"
            script_audit.update_run_status(run_id, status)
        except Exception:
            pass
        result["run_id"] = run_id

    return result


@app.post("/api/comparativa/verify-in-expected-branch")
async def verify_in_expected_branch(request: Request):
    """
    Verifica si el contacto Marca tiene homónimo con nombre EXACTO en la
    sucursal esperada. Lee del cache SQLite local (rápido y suficiente tras una
    sincronización reciente). Read-only — no muta nada.

    "Exacto" significa: el `full_name` normalizado (lowercase + trim + NFKD sin
    acentos + espacios colapsados) coincide byte por byte. Esto evita falsos
    positivos como "Juan Pérez" vs "Juan Perez" pero exige 100% identidad textual.

    Body JSON: { brand_contact_id, expected_location_id }
    Devuelve: { found, query, matches: [ {id, first_name, last_name, email, phone} ] }
    """
    import unicodedata
    try:
        body = await request.json()
    except Exception:
        body = {}
    brand_contact_id = body.get("brand_contact_id")
    expected_location_id = body.get("expected_location_id")
    if not brand_contact_id or not expected_location_id:
        raise HTTPException(status_code=400, detail="brand_contact_id y expected_location_id son requeridos.")

    BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
    brand_contact = db.get_contact_by_id(BRAND_LOCATION_ID, brand_contact_id)
    if not brand_contact:
        raise HTTPException(status_code=404, detail="Contacto Marca no está en SQLite local. Sincroniza Marca primero.")

    full_name_raw = " ".join(filter(None, [
        brand_contact.get("first_name"), brand_contact.get("last_name")
    ])).strip()
    if not full_name_raw:
        return {"ok": True, "found": False, "query": "", "matches": [],
                "reason": "El contacto Marca no tiene nombre; no se puede buscar por nombre exacto."}

    def _normalize(s):
        if not s:
            return ""
        nfkd = unicodedata.normalize("NFD", str(s))
        clean = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
        return " ".join(clean.lower().split())

    target = _normalize(full_name_raw)

    conn = db.get_db_connection()
    try:
        rows = conn.execute(
            "SELECT id, first_name, last_name, email, phone FROM contacts WHERE location_id=?",
            (expected_location_id,),
        ).fetchall()
    finally:
        conn.close()

    matches = []
    for r in rows:
        candidate = " ".join(filter(None, [r["first_name"], r["last_name"]])).strip()
        if _normalize(candidate) == target:
            matches.append({
                "id": r["id"],
                "first_name": r["first_name"],
                "last_name": r["last_name"],
                "email": r["email"],
                "phone": r["phone"],
            })

    return {
        "ok": True,
        "found": len(matches) > 0,
        "query": full_name_raw,
        "normalized_query": target,
        "matches": matches,
        "match_count": len(matches),
    }


@app.post("/api/comparativa/create-in-expected-branch")
async def create_in_expected_branch(request: Request):
    """
    Crea el contacto Marca en la sucursal esperada (mapeando custom fields por
    nombre Marca → Sucursal). Caso de uso: bucket brand_not_in_any_branch.

    Si ya existe homónimo exacto en la sucursal, NO crea — devuelve 409 con la
    sugerencia de usar el botón "Verificar" o eliminar manualmente.

    Respeta el toggle global X-Dry-Run.

    Body JSON: { brand_contact_id, expected_location_id }
    """
    import sqlite3
    from scripts.sync_missing_opps_to_brand import (
        DB_PATH, build_contact_payload, load_schemas_id_to_name, load_schemas_name_to_id,
        upsert_contact_in_db,
    )

    try:
        body = await request.json()
    except Exception:
        body = {}
    brand_contact_id = body.get("brand_contact_id")
    expected_location_id = body.get("expected_location_id")
    if not brand_contact_id or not expected_location_id:
        raise HTTPException(status_code=400, detail="brand_contact_id y expected_location_id son requeridos.")

    BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
    if expected_location_id == BRAND_LOCATION_ID:
        raise HTTPException(status_code=400, detail="La sucursal esperada no puede ser la cuenta Marca.")

    branch_token = TOKENS_CACHE.get(expected_location_id)
    if not branch_token:
        raise HTTPException(status_code=404, detail=f"No hay token para la sucursal {expected_location_id}.")

    # Modo simulación: previsualizar lo que se haría.
    if is_dry_run_request(request):
        brand_contact = db.get_contact_by_id(BRAND_LOCATION_ID, brand_contact_id)
        if not brand_contact:
            return dry_run_response("create_in_expected_branch", {
                "ok": False,
                "warning": "Contacto Marca no está en SQLite local; en live se intentaría descargarlo.",
                "brand_contact_id": brand_contact_id,
            })
        full_name = " ".join(filter(None, [brand_contact.get("first_name"), brand_contact.get("last_name")])).strip()
        return dry_run_response("create_in_expected_branch", {
            "brand_contact_id": brand_contact_id,
            "expected_location_id": expected_location_id,
            "contact_name": full_name,
            "email": brand_contact.get("email"),
            "phone": brand_contact.get("phone"),
            "custom_fields_count": len(brand_contact.get("custom_fields") or []),
        })

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        brand_row = conn.execute(
            "SELECT * FROM contacts WHERE location_id=? AND id=?",
            (BRAND_LOCATION_ID, brand_contact_id),
        ).fetchone()
        if not brand_row:
            raise HTTPException(status_code=404, detail=f"Contacto Marca {brand_contact_id} no está en SQLite.")
        brand_contact = dict(brand_row)
        # Mapear customFields del schema Marca al schema sucursal por nombre.
        brand_schema_id_to_name = load_schemas_id_to_name(conn, BRAND_LOCATION_ID, "contact")
        branch_schema_name_to_id = load_schemas_name_to_id(conn, expected_location_id, "contact")
    finally:
        conn.close()

    payload = build_contact_payload(brand_contact, brand_schema_id_to_name, branch_schema_name_to_id, expected_location_id)
    if not payload:
        raise HTTPException(status_code=400, detail="No se pudo construir el payload del contacto (falta first_name/last_name o phone/email).")

    # Crear en GHL.
    import uuid
    run_id = f"cieb-{uuid.uuid4().hex[:12]}"
    try:
        script_audit.init_audit_db()
        script_audit.create_run(
            run_id, "create_in_expected_branch",
            arguments=f"--brand-contact-id {brand_contact_id} --target-location {expected_location_id}",
            locations=[expected_location_id], execution_mode="sequential",
        )
    except Exception:
        run_id = None

    try:
        res = sync_engine.ghl_client.create_contact(branch_token, payload)
    except Exception as exc:
        error_id = error_logging.log_error("create_in_expected_branch_ghl_failed", exc, {
            "brand_contact_id": brand_contact_id,
            "expected_location_id": expected_location_id,
            "run_id": run_id,
        })
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(exc))
            except Exception: pass
        # GHL devuelve 400 con detalle "Contact with this Email/Phone already exists" cuando hay colisión.
        raise HTTPException(status_code=502, detail=f"Bucéfalo rechazó la creación: {exc} (error_id={error_id})")

    contact_obj = (res or {}).get("contact") or res or {}
    new_contact_id = contact_obj.get("id")

    # Actualizar cache local.
    if new_contact_id:
        conn = sqlite3.connect(DB_PATH)
        conn.row_factory = sqlite3.Row
        try:
            tags = brand_contact.get("tags") or []
            if isinstance(tags, str):
                import json as _json
                try: tags = _json.loads(tags) or []
                except Exception: tags = []
            upsert_contact_in_db(conn, new_contact_id, payload, expected_location_id, tags=tags)
            conn.commit()
        except Exception as exc:
            error_logging.log_error("create_in_expected_branch_cache_failed", exc, {"new_contact_id": new_contact_id})
        finally:
            conn.close()
        # Refresh autoritativo del nuevo contacto en sucursal.
        ref = sync_engine.refresh_contact_in_db(branch_token, new_contact_id, expected_location_id)
        if not ref.get("ok"):
            error_logging.log_error("create_in_expected_branch_refresh_failed",
                                    Exception(ref.get("error") or "unknown"),
                                    {"new_contact_id": new_contact_id})

    if run_id:
        try:
            script_audit.record_change(
                run_id, expected_location_id, "contact_created_in_branch",
                new_contact_id or "", "", "created",
                None,
                {"brand_contact_id": brand_contact_id, "branch_contact_id": new_contact_id,
                 "name": payload.get("firstName", "") + " " + payload.get("lastName", "")},
            )
            script_audit.update_run_status(run_id, "success")
        except Exception:
            pass

    return {
        "ok": True,
        "created": True,
        "branch_contact_id": new_contact_id,
        "expected_location_id": expected_location_id,
        "brand_contact_id": brand_contact_id,
        "run_id": run_id,
        "payload": {
            "firstName": payload.get("firstName"),
            "lastName": payload.get("lastName"),
            "email": payload.get("email"),
            "phone": payload.get("phone"),
            "custom_fields_count": len(payload.get("customFields") or []),
        },
    }


@app.post("/api/comparativa/sync-missing-contacts")
async def sync_missing_contacts(request: Request):
    """
    Sincroniza al CRM de Marca los contactos del bucket
    "contacts_in_branch_not_in_brand". Default dry-run.

    Hace doble-check por telefono -> email -> nombre antes de crear (evita
    falsos positivos del audit que solo busca por phone/email).

    Body JSON:
      {
        "dry_run": true|false,
        "contact_ids": ["id1", ...],   # opcional
        "yes": true                    # confirma cuando dry_run=false
      }
    """
    from scripts import sync_missing_contacts_to_brand as sync_script
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = bool(body.get("dry_run", True))
    contact_ids = body.get("contact_ids") or None
    confirmed = bool(body.get("yes", False))

    if not dry_run and not confirmed:
        raise HTTPException(
            status_code=400,
            detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
        )

    run_id = None
    if not dry_run:
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"smc-{uuid.uuid4().hex[:12]}"
            script_audit.create_run(
                run_id,
                sync_script.SCRIPT_NAME,
                arguments=f"--apply contact_ids={contact_ids or 'all'}",
                locations=[sync_script.BRAND_LOCATION_ID],
                execution_mode="sequential",
            )
        except Exception as e:
            error_logging.log_error("sync_missing_contacts_start_run_failed", e)
            run_id = None

    try:
        result = sync_script.run_sync(
            contact_ids=contact_ids,
            dry_run=dry_run,
            log=lambda *_a, **_kw: None,
            run_id=run_id,
        )
    except FileNotFoundError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))

    if run_id:
        try:
            status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["contacts_created"] == 0 else "completed"
            script_audit.update_run_status(run_id, status)
        except Exception:
            pass
        result["run_id"] = run_id

    return result


@app.post("/api/comparativa/sync-brand-to-branch-contacts")
async def sync_brand_to_branch_contacts(request: Request):
    """
    Sincroniza los contactos del bucket "contacts_in_brand_not_in_any_branch"
    desde Marca hacia su sucursal correspondiente (resuelta por el campo TIENDA
    + verificador). Default dry-run.

    Reglas estrictas:
      - Requiere TIENDA poblada en el contacto Y mapeable via verificador.
      - Double-check en la sucursal destino por phone -> email -> nombre.

    Body JSON:
      { "dry_run": true|false, "contact_ids": [...], "yes": true }
    """
    from scripts import sync_brand_to_branch_contacts as sync_script
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = bool(body.get("dry_run", True))
    contact_ids = body.get("contact_ids") or None
    confirmed = bool(body.get("yes", False))

    if not dry_run and not confirmed:
        raise HTTPException(
            status_code=400,
            detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
        )

    run_id = None
    if not dry_run:
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"sb2b-{uuid.uuid4().hex[:12]}"
            script_audit.create_run(
                run_id,
                sync_script.SCRIPT_NAME,
                arguments=f"--apply contact_ids={contact_ids or 'all'}",
                locations=[sync_script.BRAND_LOCATION_ID],
                execution_mode="sequential",
            )
        except Exception as e:
            error_logging.log_error("sync_brand_to_branch_start_run_failed", e)
            run_id = None

    try:
        result = sync_script.run_sync(
            contact_ids=contact_ids,
            dry_run=dry_run,
            log=lambda *_a, **_kw: None,
            run_id=run_id,
        )
    except FileNotFoundError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))

    if run_id:
        try:
            status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["contacts_created"] == 0 else "completed"
            script_audit.update_run_status(run_id, status)
        except Exception:
            pass
        result["run_id"] = run_id

    return result


@app.post("/api/comparativa/fix-tienda-from-sucursal")
async def fix_tienda_from_sucursal(request: Request):
    """
    Llena el campo TIENDA de los contactos de Marca a partir del valor del
    campo 'Sucursal' (custom field), resuelto via verificador. Aplica solo
    a contactos del bucket "contacts_in_brand_without_tienda" que tienen
    Sucursal poblada y resoluble.

    Body JSON:
      { "dry_run": true|false, "contact_ids": [...], "yes": true }
    """
    from scripts import fix_brand_tienda_from_sucursal as fix_script
    try:
        body = await request.json()
    except Exception:
        body = {}
    dry_run = bool(body.get("dry_run", True))
    contact_ids = body.get("contact_ids") or None
    confirmed = bool(body.get("yes", False))

    if not dry_run and not confirmed:
        raise HTTPException(
            status_code=400,
            detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
        )

    run_id = None
    if not dry_run:
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"fbts-{uuid.uuid4().hex[:12]}"
            script_audit.create_run(
                run_id,
                fix_script.SCRIPT_NAME,
                arguments=f"--apply contact_ids={contact_ids or 'all'}",
                locations=[fix_script.BRAND_LOCATION_ID],
                execution_mode="sequential",
            )
        except Exception as e:
            error_logging.log_error("fix_tienda_from_sucursal_start_run_failed", e)
            run_id = None

    try:
        result = fix_script.run_fix(
            contact_ids=contact_ids,
            dry_run=dry_run,
            log=lambda *_a, **_kw: None,
            run_id=run_id,
        )
    except FileNotFoundError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))
    except RuntimeError as e:
        if run_id:
            try: script_audit.update_run_status(run_id, "failed", str(e))
            except Exception: pass
        raise HTTPException(status_code=409, detail=str(e))

    if run_id:
        try:
            status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["contacts_updated"] == 0 else "completed"
            script_audit.update_run_status(run_id, status)
        except Exception:
            pass
        result["run_id"] = run_id

    return result


@app.post("/api/comparativa/sync-from-branch-contact")
async def sync_from_branch_contact(request: Request):
    """
    Actualiza un contacto de Marca usando los datos + custom fields de un
    contacto de sucursal. Sirve para el caso "ultimo restante de duplicados
    intra-Marca" donde el contacto Marca quedo sin phone/email y existe un
    contacto sucursal con el mismo nombre tambien sin phone/email.

    PUT /contacts/{brand_contact_id} con:
      - firstName, lastName (del sucursal)
      - customFields (mapeados por nombre del schema sucursal -> schema marca)

    GHL no acepta locationId/contactId/status en PUT — los omitimos.

    Body JSON: { brand_contact_id, branch_contact_id, branch_location_id }
    """
    import sqlite3
    from scripts.sync_missing_opps_to_brand import (
        BRAND_LOCATION_ID, DB_PATH,
        build_contact_payload, load_schemas_id_to_name, load_schemas_name_to_id,
        upsert_contact_in_db,
    )

    try:
        body = await request.json()
    except Exception:
        body = {}
    brand_contact_id = body.get("brand_contact_id")
    branch_contact_id = body.get("branch_contact_id")
    branch_location_id = body.get("branch_location_id")
    if not brand_contact_id or not branch_contact_id or not branch_location_id:
        raise HTTPException(status_code=400, detail="brand_contact_id, branch_contact_id y branch_location_id son requeridos.")

    if is_dry_run_request(request):
        return dry_run_response("sync_brand_from_branch_contact", {
            "brand_contact_id": brand_contact_id,
            "branch_contact_id": branch_contact_id,
            "branch_location_id": branch_location_id,
        })

    brand_token = TOKENS_CACHE.get(BRAND_LOCATION_ID)
    if not brand_token:
        raise HTTPException(status_code=404, detail="No hay token para Marca.")

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        branch_row = conn.execute(
            "SELECT * FROM contacts WHERE location_id=? AND id=?",
            (branch_location_id, branch_contact_id),
        ).fetchone()
        if not branch_row:
            raise HTTPException(status_code=404, detail=f"Contacto sucursal {branch_contact_id} no esta en SQLite.")
        brand_row = conn.execute(
            "SELECT * FROM contacts WHERE location_id=? AND id=?",
            (BRAND_LOCATION_ID, brand_contact_id),
        ).fetchone()
        if not brand_row:
            raise HTTPException(status_code=404, detail=f"Contacto Marca {brand_contact_id} no esta en SQLite.")

        branch_contact = dict(branch_row)
        # Schemas para mapear custom fields nombre→id
        branch_schema_id_to_name = load_schemas_id_to_name(conn, branch_location_id, "contact")
        brand_schema_name_to_id = load_schemas_name_to_id(conn, BRAND_LOCATION_ID, "contact")

        # build_contact_payload arma payload COMPLETO con locationId; para PUT
        # hay que quitar campos que GHL rechaza.
        payload = build_contact_payload(
            branch_contact,
            branch_schema_id_to_name,
            brand_schema_name_to_id,
            target_location_id=BRAND_LOCATION_ID,
        )
        # PUT /contacts/{id} no acepta locationId — lo quitamos.
        payload_for_put = {k: v for k, v in payload.items() if k != "locationId"}

        try:
            sync_engine.ghl_client.update_contact(brand_token, brand_contact_id, payload_for_put)
        except Exception as e:
            err_id = error_logging.log_error(
                "sync_from_branch_contact_failed", e,
                {"brand_contact_id": brand_contact_id, "branch_contact_id": branch_contact_id, "branch_location_id": branch_location_id},
            )
            raise HTTPException(status_code=502, detail=f"GHL rechazo la actualizacion: {e} | error_id={err_id}")

        # Upsert SQLite local. build_contact_payload incluye locationId; lo
        # pasamos al upsert. Reusamos el helper sobreescribiendo el id.
        upsert_contact_in_db(conn, brand_contact_id, payload, BRAND_LOCATION_ID)
        # Refresh autoritativo del contacto Marca tras update.
        ref = sync_engine.refresh_contact_in_db(brand_token, brand_contact_id, BRAND_LOCATION_ID)
        if not ref.get("ok"):
            error_logging.log_error("sync_from_branch_contact_refresh_failed",
                                    Exception(ref.get("error") or "unknown"),
                                    {"brand_contact_id": brand_contact_id})

        # Audit log
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"sfbc-{uuid.uuid4().hex[:12]}"
            script_audit.create_run(
                run_id, "sync_from_branch_contact (inline)",
                arguments=f"brand={brand_contact_id} <- branch={branch_contact_id}@{branch_location_id}",
                locations=[BRAND_LOCATION_ID], execution_mode="sequential",
            )
            cid = script_audit.record_change(
                run_id, BRAND_LOCATION_ID, "contact", brand_contact_id,
                "", "updated", dict(brand_row),
                {"source_branch": branch_location_id, "source_branch_contact_id": branch_contact_id, "payload": payload_for_put},
            )
            if cid:
                script_audit.mark_change(cid, "applied")
            script_audit.update_run_status(run_id, "completed")
        except Exception:
            pass

        return {
            "updated": True,
            "brand_contact_id": brand_contact_id,
            "branch_contact_id": branch_contact_id,
            "branch_location_id": branch_location_id,
            "fields_updated": list(payload_for_put.keys()),
            "custom_fields_count": len(payload_for_put.get("customFields", [])),
        }
    finally:
        conn.close()


@app.post("/api/comparativa/update-branch-from-brand")
async def update_branch_from_brand(request: Request):
    """
    Actualiza un contacto de sucursal con los datos + custom fields del contacto
    Marca. Caso de uso: bucket missing_in_assigned_branch, donde el contacto
    Marca tiene TIENDA poblada pero NO matchea por phone/email con la sucursal
    asignada; sin embargo, EN la sucursal asignada existe un contacto con el
    mismo nombre exacto. Esto consolida ambos lados pisando el sucursal con la
    info del Marca.

    Body JSON: { brand_contact_id, branch_contact_id, branch_location_id }
    """
    import sqlite3
    from scripts.sync_missing_opps_to_brand import (
        BRAND_LOCATION_ID, DB_PATH,
        build_contact_payload, load_schemas_id_to_name, load_schemas_name_to_id,
        upsert_contact_in_db,
    )
    try:
        body = await request.json()
    except Exception:
        body = {}
    brand_contact_id = body.get("brand_contact_id")
    branch_contact_id = body.get("branch_contact_id")
    branch_location_id = body.get("branch_location_id")
    if not brand_contact_id or not branch_contact_id or not branch_location_id:
        raise HTTPException(status_code=400, detail="brand_contact_id, branch_contact_id y branch_location_id son requeridos.")

    if is_dry_run_request(request):
        return dry_run_response("update_branch_from_brand", {
            "brand_contact_id": brand_contact_id,
            "branch_contact_id": branch_contact_id,
            "branch_location_id": branch_location_id,
        })

    branch_token = TOKENS_CACHE.get(branch_location_id)
    if not branch_token:
        raise HTTPException(status_code=404, detail=f"No hay token para {branch_location_id}")

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        brand_row = conn.execute(
            "SELECT * FROM contacts WHERE location_id=? AND id=?",
            (BRAND_LOCATION_ID, brand_contact_id),
        ).fetchone()
        if not brand_row:
            raise HTTPException(status_code=404, detail=f"Contacto Marca {brand_contact_id} no esta en SQLite.")
        branch_row = conn.execute(
            "SELECT * FROM contacts WHERE location_id=? AND id=?",
            (branch_location_id, branch_contact_id),
        ).fetchone()
        if not branch_row:
            raise HTTPException(status_code=404, detail=f"Contacto sucursal {branch_contact_id} no esta en SQLite.")

        brand_contact = dict(brand_row)
        brand_schema_id_to_name = load_schemas_id_to_name(conn, BRAND_LOCATION_ID, "contact")
        branch_schema_name_to_id = load_schemas_name_to_id(conn, branch_location_id, "contact")

        payload = build_contact_payload(
            brand_contact,
            brand_schema_id_to_name,
            branch_schema_name_to_id,
            target_location_id=branch_location_id,
        )
        # PUT /contacts/{id} no acepta locationId.
        payload_for_put = {k: v for k, v in payload.items() if k != "locationId"}

        try:
            sync_engine.ghl_client.update_contact(branch_token, branch_contact_id, payload_for_put)
        except Exception as e:
            err_id = error_logging.log_error(
                "update_branch_from_brand_failed", e,
                {"brand_contact_id": brand_contact_id, "branch_contact_id": branch_contact_id, "branch_location_id": branch_location_id},
            )
            raise HTTPException(status_code=502, detail=f"GHL rechazo la actualizacion: {e} | error_id={err_id}")

        upsert_contact_in_db(conn, branch_contact_id, payload, branch_location_id)
        # Refresh autoritativo: GET /contacts/{id} y save_single garantiza
        # consistencia con Bucéfalo. El usuario espera "todo se mapee al 100%".
        ref = sync_engine.refresh_contact_in_db(branch_token, branch_contact_id, branch_location_id)
        if not ref.get("ok"):
            error_logging.log_error("update_branch_from_brand_refresh_failed",
                                    Exception(ref.get("error") or "unknown"),
                                    {"branch_contact_id": branch_contact_id, "branch_location_id": branch_location_id})

        # script_audit
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"ubfb-{uuid.uuid4().hex[:12]}"
            script_audit.create_run(
                run_id, "update_branch_from_brand (inline)",
                arguments=f"branch={branch_contact_id}@{branch_location_id} <- brand={brand_contact_id}",
                locations=[branch_location_id], execution_mode="sequential",
            )
            cid = script_audit.record_change(
                run_id, branch_location_id, "contact", branch_contact_id,
                "", "updated", dict(branch_row),
                {"source": "brand", "source_brand_contact_id": brand_contact_id, "payload": payload_for_put},
            )
            if cid:
                script_audit.mark_change(cid, "applied")
            script_audit.update_run_status(run_id, "completed")
        except Exception:
            pass

        return {
            "updated": True,
            "branch_contact_id": branch_contact_id,
            "branch_location_id": branch_location_id,
            "brand_contact_id": brand_contact_id,
            "custom_fields_count": len(payload_for_put.get("customFields", [])),
        }
    finally:
        conn.close()


@app.post("/api/comparativa/update-brand-tienda")
async def update_brand_tienda(request: Request):
    """
    Actualiza el campo TIENDA del contacto Marca al valor indicado. Caso de uso:
    bucket present_in_other_branch_not_assigned, donde el contacto Marca esta
    realmente en una sucursal distinta a la que indica su TIENDA actual; se
    corrige la TIENDA para que apunte a la sucursal donde el contacto vive.

    Body JSON: { brand_contact_id, new_tienda_value }
    """
    import sqlite3, json as _json
    from scripts.sync_missing_opps_to_brand import BRAND_LOCATION_ID, DB_PATH, upsert_contact_in_db
    from scripts.audit_brand_vs_branches_totals import resolve_tienda_field_id
    try:
        body = await request.json()
    except Exception:
        body = {}
    brand_contact_id = body.get("brand_contact_id")
    new_tienda_value = body.get("new_tienda_value")
    if not brand_contact_id or not new_tienda_value:
        raise HTTPException(status_code=400, detail="brand_contact_id y new_tienda_value son requeridos.")

    if is_dry_run_request(request):
        return dry_run_response("update_brand_tienda", {
            "brand_contact_id": brand_contact_id,
            "new_tienda_value": new_tienda_value,
        })

    brand_token = TOKENS_CACHE.get(BRAND_LOCATION_ID)
    if not brand_token:
        raise HTTPException(status_code=404, detail="No hay token para Marca.")

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        tienda_field_id = resolve_tienda_field_id(conn, BRAND_LOCATION_ID)
        if not tienda_field_id:
            raise HTTPException(status_code=409, detail="No se pudo resolver el field_id de TIENDA en Marca. Corre la sincronizacion de metadata.")

        brand_row = conn.execute(
            "SELECT * FROM contacts WHERE location_id=? AND id=?",
            (BRAND_LOCATION_ID, brand_contact_id),
        ).fetchone()
        if not brand_row:
            raise HTTPException(status_code=404, detail=f"Contacto Marca {brand_contact_id} no esta en SQLite.")

        payload = {"customFields": [{"id": tienda_field_id, "value": new_tienda_value}]}

        try:
            sync_engine.ghl_client.update_contact(brand_token, brand_contact_id, payload)
        except Exception as e:
            err_id = error_logging.log_error(
                "update_brand_tienda_failed", e,
                {"brand_contact_id": brand_contact_id, "new_tienda_value": new_tienda_value},
            )
            raise HTTPException(status_code=502, detail=f"GHL rechazo el update: {e} | error_id={err_id}")

        # Upsert SQLite: mergear el custom_fields_json existente con el nuevo valor.
        existing = dict(brand_row)
        existing_cfs = []
        try:
            existing_cfs = _json.loads(existing.get("custom_fields_json") or "[]") or []
        except Exception:
            existing_cfs = []
        # Reemplazar o agregar el field TIENDA
        replaced = False
        for cf in existing_cfs:
            if cf.get("id") == tienda_field_id or cf.get("fieldId") == tienda_field_id:
                cf["value"] = new_tienda_value
                replaced = True
                break
        if not replaced:
            existing_cfs.append({"id": tienda_field_id, "value": new_tienda_value})
        # Construir payload sintetico para upsert_contact_in_db (espera estructura camelCase)
        local_payload = {
            "firstName": existing.get("first_name"),
            "lastName": existing.get("last_name"),
            "email": existing.get("email"),
            "phone": existing.get("phone"),
            "customFields": existing_cfs,
        }
        upsert_contact_in_db(conn, brand_contact_id, local_payload, BRAND_LOCATION_ID)
        # Refresh autoritativo Marca tras update.
        ref = sync_engine.refresh_contact_in_db(brand_token, brand_contact_id, BRAND_LOCATION_ID)
        if not ref.get("ok"):
            error_logging.log_error("update_brand_tienda_refresh_failed",
                                    Exception(ref.get("error") or "unknown"),
                                    {"brand_contact_id": brand_contact_id})

        # script_audit
        try:
            import uuid
            script_audit.init_audit_db()
            run_id = f"ubt-{uuid.uuid4().hex[:12]}"
            script_audit.create_run(
                run_id, "update_brand_tienda (inline)",
                arguments=f"brand={brand_contact_id} TIENDA->{new_tienda_value}",
                locations=[BRAND_LOCATION_ID], execution_mode="sequential",
            )
            cid = script_audit.record_change(
                run_id, BRAND_LOCATION_ID, "contact", brand_contact_id,
                tienda_field_id, "TIENDA",
                None,  # valor previo: lo dejamos null por simplicidad (esta en script_runs.context)
                {"TIENDA": new_tienda_value},
            )
            if cid:
                script_audit.mark_change(cid, "applied")
            script_audit.update_run_status(run_id, "completed")
        except Exception:
            pass

        return {
            "updated": True,
            "brand_contact_id": brand_contact_id,
            "new_tienda_value": new_tienda_value,
        }
    finally:
        conn.close()


@app.delete("/api/comparativa/contact")
def delete_comparativa_contact(contact_id: str, location_id: str, request: Request):
    """
    Elimina un contacto en GHL y limpia el snapshot SQLite local.

    Tambien borra las oportunidades cacheadas del contacto en esa location
    porque GHL hace cascade al borrar contactos. Sin rollback: GHL no permite
    undelete.

    Respeta el toggle global vía header X-Dry-Run: si está en simulación,
    devuelve un preview de cuántas opps habría borrado en cascade sin tocar
    GHL ni el cache local.

    Query params:
      - contact_id: id de GHL del contacto.
      - location_id: location (Marca o sucursal) donde vive el contacto.
    """
    if not contact_id or not location_id:
        raise HTTPException(status_code=400, detail="contact_id y location_id son requeridos.")

    token = TOKENS_CACHE.get(location_id)
    if not token:
        raise HTTPException(status_code=404, detail=f"No hay token para la location {location_id}")

    # Modo simulación: previsualizar el impacto sin tocar GHL.
    if is_dry_run_request(request):
        conn = db.get_db_connection()
        try:
            opps_count_row = conn.execute(
                "SELECT COUNT(*) AS n FROM opportunities WHERE contact_id=? AND location_id=?",
                (contact_id, location_id),
            ).fetchone()
            opps_count = opps_count_row["n"] if opps_count_row else 0
            contact_row = conn.execute(
                "SELECT first_name, last_name, email FROM contacts WHERE id=? AND location_id=?",
                (contact_id, location_id),
            ).fetchone()
        finally:
            conn.close()
        return dry_run_response("delete_contact", {
            "contact_id": contact_id,
            "location_id": location_id,
            "contact": dict(contact_row) if contact_row else None,
            "opportunities_in_cascade": opps_count,
        })

    try:
        sync_engine.ghl_client.delete_contact(token, contact_id, location_id)
    except Exception as e:
        error_id = error_logging.log_error(
            "delete_comparativa_contact_failed", e,
            {"contact_id": contact_id, "location_id": location_id},
        )
        raise HTTPException(status_code=502, detail=f"GHL rechazo la eliminacion: {e} | error_id={error_id}")

    conn = db.get_db_connection()
    deleted_opps = 0
    try:
        cur = conn.execute(
            "DELETE FROM opportunities WHERE contact_id=? AND location_id=?",
            (contact_id, location_id),
        )
        deleted_opps = cur.rowcount
        conn.execute(
            "DELETE FROM contacts WHERE id=? AND location_id=?",
            (contact_id, location_id),
        )
        conn.commit()
    except Exception as e:
        conn.rollback()
        error_logging.log_error(
            "delete_comparativa_contact_local_cleanup_failed", e,
            {"contact_id": contact_id, "location_id": location_id},
        )
        # No re-raise: ya borramos en GHL, el cleanup local es secundario.
    finally:
        conn.close()

    return {
        "deleted": True,
        "contact_id": contact_id,
        "location_id": location_id,
        "local_opps_removed": deleted_opps,
    }


# --- ENDPOINTS DE WORKFLOWS ---

from paths import EXPORTS_DIR, REPORTS_DIR  # carpetas servidas vía /api/exports/{filename}


def _find_downloadable(filename: str):
    """Busca `filename` (sin subcarpeta) en EXPORTS_DIR y luego en cada
    subcarpeta de REPORTS_DIR (audit_custom_fields/, duplicados/, drift/,
    coverage/...). Devuelve el path realpath si existe y es seguro, o None.

    Mantenemos la URL /api/exports/<file> plana (sin subcarpetas) para que el
    marcador `[DOWNLOAD]` que imprimen los scripts siga siendo simple. La
    busqueda en reports/ permite que los scripts guarden el archivo en su
    subcarpeta organizada sin tener que duplicarlo en exports/.
    """
    exports_real = os.path.realpath(EXPORTS_DIR)
    candidate = os.path.realpath(os.path.join(EXPORTS_DIR, filename))
    if candidate.startswith(exports_real + os.sep) and os.path.isfile(candidate):
        return candidate

    reports_real = os.path.realpath(REPORTS_DIR)
    if os.path.isdir(reports_real):
        for subdir in os.listdir(reports_real):
            subdir_path = os.path.realpath(os.path.join(reports_real, subdir))
            if not subdir_path.startswith(reports_real + os.sep):
                continue
            if not os.path.isdir(subdir_path):
                continue
            candidate = os.path.realpath(os.path.join(subdir_path, filename))
            if candidate.startswith(subdir_path + os.sep) and os.path.isfile(candidate):
                return candidate
    return None


@app.get("/api/exports/{filename}")
def download_export(filename: str):
    """Sirve archivos generados por scripts (xlsx/csv/json).

    Busca en `generated/exports/` y, como fallback, en cada subcarpeta de
    `generated/reports/`. Los scripts pueden imprimir
    `[DOWNLOAD] /api/exports/<file>` aunque el archivo viva en
    `generated/reports/<categoria>/<file>`.
    """
    if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
        raise HTTPException(status_code=400, detail="Nombre de archivo invalido.")
    path = _find_downloadable(filename)
    if path is None:
        raise HTTPException(status_code=404, detail="Archivo no encontrado.")
    return FileResponse(path, filename=filename)


@app.get("/api/workflows")
def get_workflows(location_id: str = None):
    """
    Retorna los workflows almacenados en SQLite, opcionalmente filtrados por location_id.
    """
    return db.get_workflows(location_id=location_id)

@app.get("/api/workflows/export")
def export_workflows(location_id: str = None, status: str = "all", q: str = None):
    """
    Exporta los workflows almacenados en SQLite a un archivo Excel, respetando filtros de la UI.
    """
    workflows = db.get_workflows(location_id=location_id)
    search_query = (q or "").strip().lower()

    def matches_filters(workflow):
        workflow_status = workflow.get("status") or ""
        if status != "all":
            if status == "active" and workflow_status not in ("active", "published"):
                return False
            if status == "inactive" and workflow_status != "inactive":
                return False
            if status == "draft" and workflow_status != "draft":
                return False

        if search_query:
            searchable_values = (
                workflow.get("name") or "",
                workflow.get("id") or "",
                workflow.get("trigger") or "",
            )
            if not any(search_query in value.lower() for value in searchable_values):
                return False
        return True

    filtered_workflows = [workflow for workflow in workflows if matches_filters(workflow)]

    workbook = Workbook()
    sheet = workbook.active
    sheet.title = "Workflows GHL"

    headers = [
        "Cuenta/Sucursal",
        "Location ID",
        "ID Workflow",
        "Nombre del Workflow",
        "Estado",
        "Trigger",
        "Fecha de Creación",
        "Fecha de Modificación",
        "Sincronizado en Local",
    ]
    sheet.append(headers)

    header_fill = PatternFill(fill_type="solid", fgColor="1F2937")
    header_font = Font(color="FFFFFF", bold=True)
    for cell in sheet[1]:
        cell.fill = header_fill
        cell.font = header_font

    status_labels = {
        "active": "Activo",
        "published": "Activo",
        "inactive": "Pausado",
        "draft": "Borrador",
    }

    for workflow in filtered_workflows:
        workflow_status = workflow.get("status") or "draft"
        sheet.append([
            workflow.get("account_name") or workflow.get("location_id") or "",
            workflow.get("location_id") or "",
            workflow.get("id") or "",
            workflow.get("name") or "",
            status_labels.get(workflow_status, workflow_status),
            workflow.get("trigger") or "",
            workflow.get("created_at") or "",
            workflow.get("updated_at") or "",
            workflow.get("synced_at") or "",
        ])

    for column_cells in sheet.columns:
        max_length = max(len(str(cell.value or "")) for cell in column_cells)
        sheet.column_dimensions[column_cells[0].column_letter].width = min(max(max_length + 2, 14), 60)

    output = BytesIO()
    workbook.save(output)
    output.seek(0)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"workflows_ghl_{timestamp}.xlsx"
    return StreamingResponse(
        output,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )

def sync_workflows_task(location_id: str = None):
    """
    Tarea de fondo para sincronizar todos los workflows (o de una cuenta específica).
    """
    try:
        accounts = sync_engine.parse_accounts_csv()
    except Exception as e:
        error_logging.log_error("sync_workflows_bg_task_load_accounts_failed", e)
        return

    if location_id:
        accounts = [acc for acc in accounts if acc["location_id"] == location_id]
    
    for acc in accounts:
        loc_id = acc["location_id"]
        nombre = acc["nombre"]
        token = acc["token"]
        try:
            workflows = sync_engine.ghl_client.get_workflows(token, loc_id)
            db.save_workflows(loc_id, workflows)
            print(f"Sincronizados {len(workflows)} workflows para {nombre} de forma asíncrona.")
        except Exception as e:
            error_logging.log_error("sync_workflows_bg_task_failed", e, {"location_id": loc_id})
            print(f"Error sincronizando workflows para {nombre}: {e}")

@app.post("/api/workflows/sync")
def trigger_workflows_sync(background_tasks: BackgroundTasks, location_id: str = None):
    """
    Sincroniza en segundo plano todos los workflows de todas las cuentas o de una en particular.
    """
    background_tasks.add_task(sync_workflows_task, location_id)
    return {"success": True, "message": "Sincronización de workflows iniciada en segundo plano."}


# --- RUTAS DE AUTOMATIZACIÓN DE WORKFLOWS CON PLAYWRIGHT ---

@app.post("/api/workflows/{workflow_id}/toggle-status")
def toggle_workflow_status(workflow_id: str, location_id: str, current_status: str, request: Request):
    """
    Dispara Playwright en segundo plano para alternar el estado del workflow.
    """
    if is_dry_run_request(request):
        return {"success": True, "dry_run": True, "task_id": None,
                "message": "Modo simulación: el toggle de workflow no se ejecutó (cambia a Live para aplicar)."}
    try:
        task_id = script_runner.start_script(
            "ghl_browser_workflow_manager.py",
            arguments=f"--action toggle-status --location {location_id} --workflow-id {workflow_id} --current-status {current_status}"
        )
        return {"success": True, "task_id": task_id, "message": "Proceso de Playwright iniciado para cambiar estado."}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/workflows/{workflow_id}/rename")
def rename_workflow(workflow_id: str, location_id: str, new_name: str, request: Request):
    """
    Dispara Playwright en segundo plano para renombrar el workflow.
    """
    import shlex
    if is_dry_run_request(request):
        return {"success": True, "dry_run": True, "task_id": None,
                "message": f"Modo simulación: el rename a '{new_name}' no se ejecutó (cambia a Live para aplicar)."}
    try:
        escaped_name = shlex.quote(new_name)
        task_id = script_runner.start_script(
            "ghl_browser_workflow_manager.py",
            arguments=f"--action rename --location {location_id} --workflow-id {workflow_id} --new-name {escaped_name}"
        )
        return {"success": True, "task_id": task_id, "message": "Proceso de Playwright iniciado para renombrar."}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.delete("/api/workflows/{workflow_id}")
def delete_workflow(workflow_id: str, location_id: str, request: Request):
    """
    Dispara Playwright en segundo plano para eliminar el workflow.
    """
    if is_dry_run_request(request):
        return {"success": True, "dry_run": True, "task_id": None,
                "message": "Modo simulación: la eliminación del workflow no se ejecutó (cambia a Live para aplicar)."}
    try:
        task_id = script_runner.start_script(
            "ghl_browser_workflow_manager.py",
            arguments=f"--action delete --location {location_id} --workflow-id {workflow_id}"
        )
        return {"success": True, "task_id": task_id, "message": "Proceso de Playwright iniciado para eliminar."}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


async def _dispatch_bulk_toggle(request: Request, target: str):
    """Helper compartido por bulk-draft, bulk-publish y bulk-delete. `target` ∈ {'draft','publish','delete'}."""
    import json as _json
    if target not in ("draft", "publish", "delete"):
        raise HTTPException(status_code=400, detail=f"target inválido: {target}")
    try:
        payload = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Body inválido (JSON esperado).")

    items = payload.get("items") if isinstance(payload, dict) else None
    if not isinstance(items, list) or not items:
        raise HTTPException(status_code=400, detail="Se esperaba 'items' como lista no vacía.")

    for i, it in enumerate(items):
        if not isinstance(it, dict) or not it.get("workflow_id") or not it.get("location_id"):
            raise HTTPException(status_code=400, detail=f"Item {i} inválido: requiere workflow_id y location_id.")

    if is_dry_run_request(request):
        return {
            "success": True,
            "dry_run": True,
            "target": target,
            "items_count": len(items),
            "task_id": None,
            "message": f"Modo simulación: bulk {target} no se ejecutó. {len(items)} workflows habrían sido afectados.",
        }

    from paths import BATCH_DIR
    batch_path = os.path.join(
        BATCH_DIR,
        f"_bulk_batch_{target}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.json",
    )
    try:
        with open(batch_path, "w", encoding="utf-8") as f:
            _json.dump(items, f, ensure_ascii=False)
    except Exception as we:
        raise HTTPException(status_code=500, detail=f"No se pudo escribir batch file: {we}")

    cli_action = {"draft": "bulk-draft", "publish": "bulk-publish", "delete": "bulk-delete"}[target]
    try:
        task_id = script_runner.start_script(
            "ghl_browser_workflow_manager.py",
            arguments=f'--action {cli_action} --batch-file "{batch_path}"',
        )
        return {
            "success": True,
            "task_id": task_id,
            "count": len(items),
            "target": target,
            "message": f"Bulk-{target} iniciado para {len(items)} workflow(s).",
        }
    except Exception as e:
        try:
            os.remove(batch_path)
        except Exception:
            pass
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/workflows/bulk-draft")
async def bulk_draft_workflows(request: Request):
    """Pone una lista de workflows en Borrador con un solo subprocess de Playwright."""
    return await _dispatch_bulk_toggle(request, "draft")


@app.post("/api/workflows/bulk-publish")
async def bulk_publish_workflows(request: Request):
    """Pone una lista de workflows en Publicado con un solo subprocess de Playwright."""
    return await _dispatch_bulk_toggle(request, "publish")


@app.post("/api/workflows/bulk-delete")
async def bulk_delete_workflows(request: Request):
    """Elimina una lista de workflows con un solo subprocess de Playwright."""
    return await _dispatch_bulk_toggle(request, "delete")


@app.post("/api/workflows/{workflow_id}/scan-anomalies")
def scan_workflow_anomalies(workflow_id: str, location_id: str):
    """Lanza el detector de anomalías Playwright para un workflow individual."""
    try:
        task_id = script_runner.start_script(
            "ghl_browser_workflow_anomaly_scanner.py",
            arguments=f"--location {location_id} --workflow-id {workflow_id}",
        )
        return {"success": True, "task_id": task_id,
                "message": "Escaneo de anomalías iniciado."}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/scripts/stop/{task_id}")
def stop_script_task(task_id: str):
    """Cancela un script en curso terminando su subproceso. Usado por el botón
    'Cancelar' del panel de progreso del bulk."""
    ok, msg = script_runner.stop_task(task_id)
    if not ok:
        raise HTTPException(status_code=400, detail=msg)
    return {"success": True, "message": msg}


@app.get("/api/workflows/anomaly-report-xlsx/{filename}")
def download_anomaly_report_xlsx(filename: str):
    """Genera al vuelo un XLSX a partir del CSV agregado de anomalías (una fila por
    workflow). Primera columna = hyperlink al editor en Bucéfalo. Filas coloreadas según
    el tipo de anomalía más severo presente."""
    import re as _re
    from fastapi.responses import FileResponse
    if not _re.match(r"^workflow_anomalies_\d{8}_\d{6}\.csv$", filename):
        raise HTTPException(status_code=400, detail="Nombre de archivo inválido.")
    from paths import REPORT_WORKFLOW_ANOMALIES
    csv_path = os.path.join(REPORT_WORKFLOW_ANOMALIES, filename)
    if not os.path.isfile(csv_path):
        raise HTTPException(status_code=404, detail="Reporte no encontrado.")
    xlsx_filename = filename.replace(".csv", ".xlsx")
    xlsx_path = os.path.join(REPORT_WORKFLOW_ANOMALIES, xlsx_filename)
    # Regenerar si el XLSX es más viejo que el CSV, o si no existe.
    needs_build = (not os.path.isfile(xlsx_path)) or os.path.getmtime(xlsx_path) < os.path.getmtime(csv_path)
    if needs_build:
        try:
            import csv as _csv
            import openpyxl
            from openpyxl.styles import Font, PatternFill, Alignment
            wb = openpyxl.Workbook()
            ws = wb.active
            ws.title = "Anomalías"
            header_font = Font(bold=True, color="FFFFFF")
            header_fill = PatternFill("solid", fgColor="7C3AED")
            link_font = Font(color="1F6FEB", underline="single")
            wrap_alignment = Alignment(wrap_text=True, vertical="top")
            top_alignment = Alignment(vertical="top")
            # Colores ordenados por severidad (más severo primero).
            severity_order = ["ghl_native_error", "unresolved_field_id", "alert_icon", "n_button_alert"]
            type_colors = {
                "ghl_native_error": "FCA5A5",
                "unresolved_field_id": "FDBA74",
                "alert_icon": "FCD34D",
                "n_button_alert": "F9A8D4",
            }

            def _most_severe_color(types_csv):
                types = [t.strip() for t in (types_csv or "").split(",") if t.strip()]
                for t in severity_order:
                    if t in types:
                        return type_colors[t]
                return None

            widths = {
                "workflow_url": 60, "account_name": 28, "workflow_name": 34,
                "workflow_id": 38, "workflow_status": 14, "anomalies_count": 12,
                "anomaly_types": 24, "affected_nodes": 50, "details": 60,
                "screenshot_path": 60,
            }
            multiline_cols = {"affected_nodes", "details"}

            with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
                reader = _csv.reader(f)
                headers = next(reader, [])
                ws.append(headers)
                for c in range(1, len(headers) + 1):
                    cell = ws.cell(row=1, column=c)
                    cell.font = header_font
                    cell.fill = header_fill
                    cell.alignment = Alignment(horizontal="center", vertical="center")
                # Localizar columnas clave
                col_idx = {h: i for i, h in enumerate(headers)}
                url_col = col_idx.get("workflow_url")
                types_col = col_idx.get("anomaly_types")

                row_idx = 2
                for row in reader:
                    ws.append(row)
                    # Hyperlink en la primera columna
                    if url_col is not None and url_col < len(row) and row[url_col]:
                        cell = ws.cell(row=row_idx, column=url_col + 1)
                        cell.hyperlink = row[url_col]
                        cell.font = link_font
                        cell.alignment = top_alignment
                    # Wrap text en columnas multilínea
                    for h in multiline_cols:
                        if h in col_idx:
                            ws.cell(row=row_idx, column=col_idx[h] + 1).alignment = wrap_alignment
                    # Color de fila según tipo más severo
                    if types_col is not None and types_col < len(row):
                        color = _most_severe_color(row[types_col])
                        if color:
                            fill = PatternFill("solid", fgColor=color)
                            for c in range(1, len(headers) + 1):
                                # No pisar la fuente azul del hyperlink, solo el fill.
                                ws.cell(row=row_idx, column=c).fill = fill
                    row_idx += 1

                for i, h in enumerate(headers, 1):
                    ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = widths.get(h, 18)
                ws.freeze_panes = "A2"
            wb.save(xlsx_path)
        except Exception as e:
            raise HTTPException(status_code=500, detail=f"No se pudo generar XLSX: {e}")
    return FileResponse(xlsx_path, filename=xlsx_filename,
                        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")


@app.post("/api/workflows/bulk-scan-anomalies")
async def bulk_scan_workflow_anomalies(request: Request):
    """Escanea anomalías para una lista de workflows con un solo subprocess de Playwright."""
    import json as _json
    try:
        payload = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Body inválido (JSON esperado).")

    items = payload.get("items") if isinstance(payload, dict) else None
    if not isinstance(items, list) or not items:
        raise HTTPException(status_code=400, detail="Se esperaba 'items' como lista no vacía.")
    for i, it in enumerate(items):
        if not isinstance(it, dict) or not it.get("workflow_id") or not it.get("location_id"):
            raise HTTPException(status_code=400, detail=f"Item {i} inválido: requiere workflow_id y location_id.")

    from paths import BATCH_DIR
    batch_path = os.path.join(
        BATCH_DIR,
        f"_bulk_batch_scan_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.json",
    )
    try:
        with open(batch_path, "w", encoding="utf-8") as f:
            _json.dump(items, f, ensure_ascii=False)
    except Exception as we:
        raise HTTPException(status_code=500, detail=f"No se pudo escribir batch file: {we}")

    try:
        task_id = script_runner.start_script(
            "ghl_browser_workflow_anomaly_scanner.py",
            arguments=f'--batch-file "{batch_path}"',
        )
        return {"success": True, "task_id": task_id, "count": len(items),
                "target": "scan-anomalies",
                "message": f"Escaneo de anomalías iniciado para {len(items)} workflow(s)."}
    except Exception as e:
        try:
            os.remove(batch_path)
        except Exception:
            pass
        raise HTTPException(status_code=500, detail=str(e))

# --- SESIÓN DEL NAVEGADOR (Playwright) ---

from paths import SESSION_FILE as SESSION_FILE_PATH


@app.get("/api/browser-session/status")
def browser_session_status():
    """Devuelve el estado de generated/browser/session.json (existe, antigüedad en horas)."""
    import time
    exists = os.path.exists(SESSION_FILE_PATH)
    age_hours = None
    if exists:
        age_hours = round((time.time() - os.path.getmtime(SESSION_FILE_PATH)) / 3600.0, 1)
    return {"exists": exists, "age_hours": age_hours, "stale": (age_hours or 0) > 24}


@app.post("/api/browser-session/refresh")
def browser_session_refresh():
    """Lanza el generador de sesión vía script_runner para que sus logs sean streameables
    por SSE. Devuelve task_id; el frontend se conecta a /api/scripts/stream/{task_id}
    para ver el progreso paso a paso (con o sin auto-login según haya .env configurado).
    """
    try:
        task_id = script_runner.start_script("ghl_browser_session_generator.py")
        return {
            "success": True,
            "task_id": task_id,
            "message": "Generador de sesión iniciado. Sigue el progreso en vivo.",
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


# --- CONTROLES DE SINCRONIZACIÓN ---

@app.post("/api/sync")
def trigger_global_sync():
    """
    Dispara la sincronización asíncrona de todas las cuentas.
    """
    return sync_engine.sync_all_accounts()

@app.post("/api/sync/metadata")
def trigger_metadata_sync():
    """
    Dispara la sincronización asíncrona solo de schemas/metadata de todas las cuentas.
    """
    return sync_engine.sync_all_metadata()

@app.get("/api/sync/progress")
def get_sync_progress():
    """
    Retorna el estado y avance de la sincronización en vivo.
    """
    return sync_engine.get_sync_progress()

@app.post("/api/sync/{location_id}")
def trigger_single_sync(location_id: str, background_tasks: BackgroundTasks):
    """
    Dispara la sincronización asíncrona de una cuenta específica.
    """
    token = TOKENS_CACHE.get(location_id)
    if not token:
        raise HTTPException(status_code=404, detail="Token no encontrado para la ubicación seleccionada.")
    
    def run_sync():
        sync_engine.sync_account(location_id, token)
        
    background_tasks.add_task(run_sync)
    return {"message": f"Sincronización iniciada para la cuenta {location_id} en segundo plano."}

# --- CONTROLADOR DE SCRIPTS PYTHON ---

@app.get("/api/scripts")
def get_scripts():
    """
    Retorna la lista de todos los scripts de Python categorizados y si existen en el sistema.
    """
    return script_runner.get_available_scripts()


@app.get("/api/scripts/{script_name}/metadata")
def get_script_metadata(script_name: str):
    try:
        return script_runner.get_script_metadata(script_name)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))


@app.put("/api/scripts/{script_name}/metadata")
async def update_script_metadata(script_name: str, request: Request):
    try:
        body = await request.json()
        metadata = body.get("metadata", body)
        return {"success": True, "metadata": script_runner.update_script_metadata(script_name, metadata)}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"No se pudo guardar la metadata: {str(e)}")


@app.delete("/api/scripts/{script_name}")
def delete_script(script_name: str):
    try:
        return {"success": True, **script_runner.delete_script(script_name)}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"No se pudo borrar el script: {str(e)}")

@app.post("/api/scripts/{script_name}/run")
async def run_script(script_name: str, request: Request):
    """
    Ejecuta un script de Python con argumentos opcionales.
    """
    try:
        body = await request.json()
    except Exception:
        body = {}
        
    arguments = body.get("arguments", "")
    locations = body.get("locations") or []
    all_locations = bool(body.get("all_locations", False))
    execution_mode = body.get("execution_mode", "sequential")
    if execution_mode not in ("sequential", "parallel"):
        execution_mode = "sequential"
    if all_locations and execution_mode == "parallel":
        locations = [acc["location_id"] for acc in db.get_accounts() if acc.get("type") == "branch"]
        all_locations = False
    
    try:
        task_id = script_runner.start_script(
            script_name,
            arguments=arguments,
            locations=locations,
            all_locations=all_locations,
            execution_mode=execution_mode
        )
        return {
            "success": True,
            "task_id": task_id,
            "message": f"Ejecución del script {script_name} iniciada."
        }
    except FileNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"No se pudo iniciar el script: {str(e)}")

@app.get("/api/scripts/stream/{task_id}")
def stream_script_logs(task_id: str):
    """
    Endpoint SSE (Server-Sent Events) para transmitir la terminal en vivo de un script.
    """
    return StreamingResponse(
        script_runner.get_script_log_stream(task_id),
        media_type="text/event-stream"
    )

@app.get("/api/scripts/status/{task_id}")
def get_script_status(task_id: str):
    status = script_runner.get_task_status(task_id)
    if not status:
        raise HTTPException(status_code=404, detail="Tarea no encontrada")
    return status


@app.get("/api/scripts/logs/{task_id}")
def download_script_log(task_id: str):
    """
    Descarga el log completo persistido del run (archivo plano por task_id).
    Soporta runs ya finalizados y runs en vivo: el archivo es line-buffered,
    asi que el contenido al momento de la peticion incluye todo lo emitido.
    """
    path = script_runner.get_task_log_path(task_id)
    if not path or not os.path.exists(path):
        raise HTTPException(status_code=404, detail="Log no encontrado para ese task_id.")
    return FileResponse(
        path,
        media_type="text/plain; charset=utf-8",
        filename=f"{task_id}.log",
    )


@app.get("/api/scripts/logs/{task_id}/tail")
def tail_script_log(task_id: str, request: Request, from_byte: int = 0):
    """
    Devuelve el contenido del log desde el byte indicado (clamped al final).
    Sirve para que el cliente rehidrate el buffer tras una desconexion del
    SSE sin tener que re-pedir todo el log. Respuesta JSON con:
        { "from_byte": int, "next_byte": int, "size": int, "content": str }
    """
    # `from_byte` se acepta tanto como query param `from_byte` como `from`
    # (FastAPI no permite usar la palabra reservada `from` como nombre de param).
    qp = request.query_params
    if "from" in qp:
        try:
            from_byte = int(qp["from"])
        except (TypeError, ValueError):
            from_byte = 0
    if from_byte < 0:
        from_byte = 0

    path = script_runner.get_task_log_path(task_id)
    if not path or not os.path.exists(path):
        raise HTTPException(status_code=404, detail="Log no encontrado para ese task_id.")

    try:
        size = os.path.getsize(path)
        if from_byte > size:
            from_byte = size
        with open(path, "rb") as fh:
            fh.seek(from_byte)
            chunk = fh.read()
        return {
            "from_byte": from_byte,
            "next_byte": size,
            "size": size,
            "content": chunk.decode("utf-8", errors="replace"),
        }
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"No se pudo leer el log: {exc}")

@app.get("/api/scripts/runs")
def get_script_runs(limit: int = 20):
    return script_audit.list_runs(limit=limit)

@app.get("/api/scripts/runs/{run_id}/changes")
def get_script_run_changes(run_id: str):
    return script_audit.list_changes(run_id)


@app.get("/api/errors")
def get_error_logs(limit: int = 50):
    return db.get_error_logs(limit=limit)

@app.post("/api/scripts/runs/{run_id}/pause")
def pause_script_run(run_id: str):
    script_audit.set_control(run_id, pause=True)
    script_audit.update_run_status(run_id, "paused")
    return {"success": True, "message": "Pausa solicitada."}

@app.post("/api/scripts/runs/{run_id}/resume")
def resume_script_run(run_id: str):
    script_audit.set_control(run_id, pause=False)
    script_audit.update_run_status(run_id, "running")
    return {"success": True, "message": "Reanudación solicitada."}

@app.post("/api/scripts/runs/{run_id}/stop")
def stop_script_run(run_id: str):
    script_audit.set_control(run_id, stop=True, pause=False)
    script_audit.update_run_status(run_id, "stop_requested")
    return {"success": True, "message": "Detención segura solicitada."}

@app.post("/api/scripts/runs/{run_id}/rollback")
def rollback_script_run(run_id: str):
    try:
        return {"success": True, **script_audit.rollback_run(run_id)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

if __name__ == "__main__":
    import os
    import sys
    import socket
    import json
    import threading
    import time
    import webbrowser

    # 1. Determinar puerto base (8000 por defecto o desde variable de entorno/argumento)
    port = 8000
    if os.environ.get("PORT"):
        try:
            port = int(os.environ.get("PORT"))
        except ValueError:
            pass
    for arg in sys.argv:
        if arg.startswith("--port="):
            try:
                port = int(arg.split("=")[1])
            except ValueError:
                pass

    # 2. Buscar primer puerto libre disponible
    def is_port_in_use(p):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            try:
                s.bind(('127.0.0.1', p))
                return False
            except OSError:
                return True

    original_port = port
    while is_port_in_use(port):
        port += 1

    if port != original_port:
        print(f"[SISTEMA] Puerto {original_port} en uso. Redireccionando al puerto libre: {port}")

    # 3. Guardar información del servidor para el stop.bat
    server_info = {
        "port": port,
        "pid": os.getpid()
    }
    try:
        import paths as _paths
        with open(_paths.SERVER_INFO, "w", encoding="utf-8") as f:
            json.dump(server_info, f, indent=4)
    except Exception as e:
        print(f"[ADVERTENCIA] No se pudo guardar el archivo de información del servidor: {e}")

    # 4. Iniciar hilo secundario para abrir el navegador de forma dinámica.
    #    El query param `?_=<timestamp>` fuerza al browser a tratar la URL como
    #    nueva y no reutilizar pestañas viejas que pudieran tener cargado otro
    #    proyecto en 127.0.0.1:8000 (p.ej. el Transcriptor). Sin esto, Edge/Chrome
    #    cambian a la pestaña existente sin recargar.
    def open_browser_delayed():
        time.sleep(1.5)
        url = f"http://127.0.0.1:{port}/?_={int(time.time())}"
        print(f"[SISTEMA] Abriendo navegador en la dirección: {url}")
        webbrowser.open(url, new=2)  # new=2 = "new tab if possible"

    threading.Thread(target=open_browser_delayed, daemon=True).start()

    # 5. Iniciar servidor FastAPI con uvicorn y recarga automática
    uvicorn.run("main:app", host="127.0.0.1", port=port, reload=True)
