#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fill_sucursal_tienda_from_location.py

Llena los campos personalizados "Sucursal" y "TIENDA" en contactos y oportunidades
usando el Verificador CSV como fuente autoritativa.

Mapeo:
    contacto/oportunidad.locationId  ->  CSV "ID LOCATION BUCEFALO"
        columna "SUCURSAL"  ->  campo personalizado "Sucursal"
        columna "TIENDA"    ->  campo personalizado "TIENDA"

Regla de comparacion:
    El match contra el verificador CSV es 100% estricto (byte por byte).
    Cualquier diferencia (espacio extra, mayuscula distinta, acentos, espacios
    internos duplicados) se considera invalido y dispara la correccion forzosa.

Modos de ejecucion:
    LOCAL (default)  --> usa el cache SQLite y el verificador CSV para decidir
                          que registros necesitan correccion. Solo hace PUT a la
                          API para los que cambian. Mucho mas rapido.
    FRESH (--fresh) --> mantiene el flujo anterior: GET por contacto + GET por
                          oportunidad contra la API en tiempo real. Util cuando
                          se sospecha que el cache SQLite esta desactualizado.

Frescura del cache (solo modo local):
    Si el ultimo synced_at de los contactos/oportunidades de la location es
    mayor a 24h, el script aborta con un warning. Pasa --force-stale para
    ignorar el chequeo y correr de todas formas con el cache disponible.

Orden de ejecucion por location:
    1. Contactos     -> se llenan Sucursal + TIENDA
    2. Oportunidades -> se llenan Sucursal + TIENDA

Uso:
    python scripts/fill_sucursal_tienda_from_location.py --location <id>
    python scripts/fill_sucursal_tienda_from_location.py --all
    python scripts/fill_sucursal_tienda_from_location.py --location <id> --apply
    python scripts/fill_sucursal_tienda_from_location.py --all --apply
    python scripts/fill_sucursal_tienda_from_location.py --location <id> --fresh
    python scripts/fill_sucursal_tienda_from_location.py --all --force-stale
"""

import argparse
import concurrent.futures
import csv
import json
import os
import sqlite3
import sys
import time
import warnings
from datetime import datetime, timedelta
from urllib.parse import parse_qs, urlparse

warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version!")
import requests

ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
    sys.path.insert(0, ROOT_DIR)

import script_audit   # noqa: E402
import sync_engine    # noqa: E402
import error_logging  # noqa: E402
from paths import DB_PATH  # noqa: E402

BASE_URL           = "https://services.leadconnectorhq.com"
API_VERSION        = "2021-07-28"
OBJECT_API_VERSION = "2021-04-15"
VERIFIER_FILENAME  = "Monte Providencia - Verificador de sucursales y correos - Sucursales.csv"

# Nombres exactos de los campos en el object schema de GHL
FIELD_SUCURSAL = "Sucursal"
FIELD_TIENDA   = "TIENDA"

# Frescura: si el ultimo synced_at supera este umbral, el script se rehusa a
# correr en modo local salvo que pase --force-stale.
STALE_CACHE_HOURS = 24

# Paralelismo de PUTs entre registros de UNA misma location. Los PUTs comparten
# token y por lo tanto comparten rate limit (110ms entre requests). Subir esto
# arriba de ~3 no rinde nada porque _wait() los serializa de hecho.
INTRA_LOCATION_PUT_WORKERS = 3

_last_request_by_token: dict = {}

# ---------------------------------------------------------------------------
# HTTP / rate limit
# ---------------------------------------------------------------------------

def _wait(token: str):
    now = time.time()
    elapsed = now - _last_request_by_token.get(token, 0)
    if elapsed < 0.110:
        time.sleep(0.110 - elapsed)
    _last_request_by_token[token] = time.time()


def ghl_request(method: str, endpoint: str, token: str, *,
                params=None, json_body=None, version=API_VERSION):
    _wait(token)
    url = endpoint if endpoint.startswith("http") else f"{BASE_URL}{endpoint}"
    headers = {
        "Accept": "application/json",
        "Authorization": f"Bearer {token}",
        "Version": version,
        "Content-Type": "application/json",
    }
    resp = requests.request(method, url, headers=headers,
                            params=params, json=json_body, timeout=45)
    try:
        resp.raise_for_status()
    except requests.HTTPError as exc:
        eid = error_logging.log_error("fill_sucursal_tienda_http_error", exc, {
            "method": method, "url": url,
            "status": resp.status_code, "body": resp.text[:2000],
        })
        print(f"  HTTP {resp.status_code} {method} {url} | error_id={eid}")
        if resp.text:
            print(f"  GHL resp: {resp.text[:500]}")
        raise
    return {} if resp.status_code == 204 else resp.json()


def clean(v) -> str:
    return str(v or "").strip()


def values_match_strict(current, target) -> bool:
    """
    Comparacion 100% estricta. None / ausencia se tratan como cadena vacia.
    Cualquier diferencia (espacios, mayusculas, acentos) cuenta como no-match.
    """
    cur = "" if current is None else str(current)
    tgt = "" if target  is None else str(target)
    return cur == tgt


def get_custom_field_value(record_or_cfs, field_id):
    """
    Acepta un dict de registro completo o una lista de customFields ya parseada.
    GHL devuelve keys distintas segun el objeto:
       contacts      -> "value"
       opportunities -> "fieldValueString" / "fieldValue"
    """
    if not field_id:
        return None
    if isinstance(record_or_cfs, dict):
        cfs = record_or_cfs.get("customFields") or record_or_cfs.get("custom_fields") or []
    else:
        cfs = record_or_cfs or []
    for f in cfs:
        if f.get("id") != field_id and f.get("fieldId") != field_id:
            continue
        for key in ("value", "fieldValue", "fieldValueString"):
            val = f.get(key)
            if val is not None:
                return val
        return None
    return None


def record_label(record: dict) -> str:
    return (
        record.get("contactName")
        or record.get("name")
        or " ".join(filter(None, [record.get("firstName"), record.get("lastName")]))
        or record.get("email")
        or record.get("phone")
        or record.get("id", "?")
    )

# ---------------------------------------------------------------------------
# Verificador CSV
# ---------------------------------------------------------------------------

def load_verifier_map() -> dict:
    """
    Retorna  {location_id: {"sucursal": str, "tienda": str, "sc_name": str}}
    Ignora la fila de la cuenta principal (SUCURSAL == "-").
    """
    path = os.path.join(ROOT_DIR, VERIFIER_FILENAME)
    if not os.path.exists(path):
        raise FileNotFoundError(f"Verificador no encontrado: {path}")

    result = {}
    with open(path, mode="r", encoding="utf-8-sig", newline="") as fh:
        for row in csv.DictReader(fh):
            loc_id   = clean(row.get("ID LOCATION BUCEFALO"))
            sucursal = clean(row.get("SUCURSAL"))
            tienda   = clean(row.get("TIENDA"))
            sc_name  = clean(row.get("SC BUCEFALO"))
            if not loc_id or sucursal == "-" or tienda in ("-", "CUENTA PRINCIPAL", ""):
                continue
            if loc_id not in result:
                result[loc_id] = {"sucursal": sucursal, "tienda": tienda, "sc_name": sc_name}
    return result

# ---------------------------------------------------------------------------
# Resolver de field IDs - local primero, fallback API
# ---------------------------------------------------------------------------

def resolve_field_ids_local(location_id: str, object_key: str) -> dict:
    """
    Lee object_schemas en SQLite y retorna {field_name: field_id} para Sucursal y TIENDA.
    Devuelve dict vacio si no hay nada local.
    """
    if not os.path.exists(DB_PATH):
        return {}
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        rows = conn.execute(
            "SELECT field_id, field_name FROM object_schemas "
            "WHERE location_id = ? AND object_key = ? "
            "  AND field_name IN (?, ?)",
            (location_id, object_key, FIELD_SUCURSAL, FIELD_TIENDA),
        ).fetchall()
        return {r["field_name"]: r["field_id"] for r in rows}
    finally:
        conn.close()


def resolve_field_ids_api(location_id: str, token: str, object_key: str) -> dict:
    """Fallback: pide el schema a GHL si SQLite no tiene cache."""
    ghl_request("GET", "/objects/", token,
                params={"locationId": location_id},
                version=OBJECT_API_VERSION)
    data = ghl_request("GET", f"/objects/{object_key}", token,
                       params={"locationId": location_id},
                       version=OBJECT_API_VERSION)
    out = {}
    for f in data.get("fields", []):
        name = f.get("name")
        fid = f.get("id")
        if name in (FIELD_SUCURSAL, FIELD_TIENDA) and fid:
            out[name] = fid
    return out


def resolve_field_ids(location_id: str, token: str, object_key: str,
                       prefer_local: bool) -> dict:
    """En modo local intenta primero la tabla object_schemas; si falta algun
    campo, hace fallback a la API. En modo fresh siempre va a la API."""
    if prefer_local:
        local = resolve_field_ids_local(location_id, object_key)
        if FIELD_SUCURSAL in local and FIELD_TIENDA in local:
            return local
        try:
            api = resolve_field_ids_api(location_id, token, object_key)
            # Mezclar: lo local manda, completar con API
            merged = dict(api)
            merged.update(local)
            return merged
        except Exception as exc:
            print(f"  WARN no se pudo resolver schema via API ({object_key}): {exc}")
            return local
    return resolve_field_ids_api(location_id, token, object_key)

# ---------------------------------------------------------------------------
# Frescura del cache
# ---------------------------------------------------------------------------

def _parse_synced_at(value: str):
    if not value:
        return None
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
        try:
            return datetime.strptime(value, fmt)
        except ValueError:
            continue
    return None


def get_cache_freshness(location_id: str) -> dict:
    """
    Retorna {contacts_synced_at, opps_synced_at, oldest, hours_old}
    para evaluar si vale la pena confiar en SQLite.
    """
    if not os.path.exists(DB_PATH):
        return {"contacts_synced_at": None, "opps_synced_at": None,
                "oldest": None, "hours_old": None}
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        c_row = conn.execute(
            "SELECT MAX(synced_at) AS s FROM contacts WHERE location_id = ?",
            (location_id,),
        ).fetchone()
        o_row = conn.execute(
            "SELECT MAX(synced_at) AS s FROM opportunities WHERE location_id = ?",
            (location_id,),
        ).fetchone()
    finally:
        conn.close()

    c_ts = _parse_synced_at(c_row["s"]) if c_row and c_row["s"] else None
    o_ts = _parse_synced_at(o_row["s"]) if o_row and o_row["s"] else None
    timestamps = [t for t in (c_ts, o_ts) if t]
    oldest = min(timestamps) if timestamps else None
    hours_old = None
    if oldest:
        hours_old = (datetime.now() - oldest).total_seconds() / 3600.0
    return {
        "contacts_synced_at": c_row["s"] if c_row else None,
        "opps_synced_at": o_row["s"] if o_row else None,
        "oldest": oldest,
        "hours_old": hours_old,
    }

# ---------------------------------------------------------------------------
# Lectura local desde SQLite
# ---------------------------------------------------------------------------

def load_contacts_local(location_id: str) -> list:
    if not os.path.exists(DB_PATH):
        return []
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        rows = conn.execute(
            "SELECT id, first_name, last_name, email, phone, custom_fields_json "
            "FROM contacts WHERE location_id = ?",
            (location_id,),
        ).fetchall()
    finally:
        conn.close()
    out = []
    for r in rows:
        try:
            cfs = json.loads(r["custom_fields_json"]) if r["custom_fields_json"] else []
        except Exception:
            cfs = []
        out.append({
            "id": r["id"],
            "firstName": r["first_name"],
            "lastName": r["last_name"],
            "email": r["email"],
            "phone": r["phone"],
            "customFields": cfs,
        })
    return out


def load_opportunities_local(location_id: str) -> list:
    if not os.path.exists(DB_PATH):
        return []
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    try:
        rows = conn.execute(
            "SELECT id, name, contact_id, custom_fields_json "
            "FROM opportunities WHERE location_id = ?",
            (location_id,),
        ).fetchall()
    finally:
        conn.close()
    out = []
    for r in rows:
        try:
            cfs = json.loads(r["custom_fields_json"]) if r["custom_fields_json"] else []
        except Exception:
            cfs = []
        out.append({
            "id": r["id"],
            "name": r["name"],
            "contactId": r["contact_id"],
            "customFields": cfs,
        })
    return out

# ---------------------------------------------------------------------------
# Lectura FRESH desde API (flujo anterior, conservado como fallback)
# ---------------------------------------------------------------------------

def get_all_contacts_api(location_id: str, token: str) -> list:
    contacts, seen_ids = [], set()
    start_after = None
    start_after_id = None
    while True:
        params = {"locationId": location_id, "limit": 100}
        if start_after:
            params["startAfter"] = start_after
        if start_after_id:
            params["startAfterId"] = start_after_id

        data  = ghl_request("GET", "/contacts/", token, params=params)
        batch = data.get("contacts", [])
        if not batch:
            break

        for c in batch:
            cid = c.get("id")
            if cid and cid not in seen_ids:
                seen_ids.add(cid)
                contacts.append(c)

        meta = data.get("meta", {})
        total_reported = meta.get("total", 0) or 0
        if len(batch) < 100 or (total_reported > 0 and len(contacts) >= total_reported):
            break

        next_url = meta.get("nextPageUrl")
        if not next_url:
            break

        queries = parse_qs(urlparse(next_url).query)
        cursors = queries.get("startAfter")
        cursor_ids = queries.get("startAfterId")

        start_after = cursors[0] if cursors else None
        start_after_id = cursor_ids[0] if cursor_ids else None

        if not start_after and not start_after_id:
            break

    return contacts


def get_all_opportunities_api(location_id: str, token: str) -> list:
    opportunities = []
    page = 1
    while True:
        data  = ghl_request("POST", "/opportunities/search", token,
                            json_body={"locationId": location_id, "limit": 100, "page": page})
        batch = data.get("opportunities", [])
        if not batch:
            break
        opportunities.extend(batch)
        total = (data.get("meta") or {}).get("total") or 0
        if total and len(opportunities) >= total:
            break
        page += 1
    return opportunities


def get_contact_full_api(location_id: str, contact_id: str, token: str) -> dict:
    data = ghl_request("GET", f"/contacts/{contact_id}", token,
                       params={"locationId": location_id})
    return data.get("contact") if isinstance(data.get("contact"), dict) else data


def get_opportunity_full_api(location_id: str, opp_id: str, token: str) -> dict:
    data = ghl_request("GET", f"/opportunities/{opp_id}", token,
                       params={"locationId": location_id})
    return data.get("opportunity") if isinstance(data.get("opportunity"), dict) else data

# ---------------------------------------------------------------------------
# Plan: que registros necesitan correccion
# ---------------------------------------------------------------------------

def plan_corrections(records: list, sucursal_field_id, tienda_field_id,
                     sucursal_val: str, tienda_val: str) -> list:
    """
    Para cada registro decide si necesita PUT. Retorna lista con:
        {record, field_updates: [(name, fid, current, target, was_wrong)], any_wrong}
    field_updates siempre incluye AMBOS campos (Sucursal y TIENDA) si tienen
    field_id, para que el PUT actualice los dos juntos (idempotente).
    """
    plans = []
    field_specs = []
    if sucursal_field_id and sucursal_val:
        field_specs.append((FIELD_SUCURSAL, sucursal_field_id, sucursal_val))
    if tienda_field_id and tienda_val:
        field_specs.append((FIELD_TIENDA, tienda_field_id, tienda_val))

    if not field_specs:
        return plans

    for record in records:
        updates = []
        any_wrong = False
        for name, fid, target in field_specs:
            current = get_custom_field_value(record, fid)
            was_wrong = not values_match_strict(current, target)
            if was_wrong:
                any_wrong = True
            updates.append((name, fid, current, target, was_wrong))
        plans.append({
            "record": record,
            "field_updates": updates,
            "any_wrong": any_wrong,
        })
    return plans

# ---------------------------------------------------------------------------
# Ejecucion de un PUT
# ---------------------------------------------------------------------------

def apply_correction(*, plan, object_type, token, location_id, run_id,
                     dry_run, quiet, log_lock=None):
    """Ejecuta un PUT para un plan ya construido. Retorna el resultado string."""
    record = plan["record"]
    updates = plan["field_updates"]
    any_wrong = plan["any_wrong"]
    record_id = record.get("id")
    label = record_label(record)
    verb = "DRY" if dry_run else "PUT"

    # Logging
    lines = []
    for name, _, current, target, was_wrong in updates:
        if was_wrong:
            lines.append(f"    {verb} [{object_type}] {label} | "
                         f"{name}: '{current or '(vacio)'}' -> '{target}'  [CORRECCION]")
        elif not quiet:
            lines.append(f"    {verb} [{object_type}] {label} | "
                         f"{name}: '{target}'  [confirmar]")
    if lines:
        msg = "\n".join(lines)
        if log_lock:
            with log_lock:
                print(msg)
        else:
            print(msg)

    if dry_run:
        return "planned_fix" if any_wrong else "planned_confirm"

    # Solo registramos cambio real en auditoria
    for name, fid, current, target, was_wrong in updates:
        if was_wrong:
            script_audit.record_change(run_id, location_id, object_type,
                                       record_id, fid, name, current, target)

    payload = [{"id": fid, "value": tgt} for _, fid, _, tgt, _ in updates]
    try:
        if object_type == "contact":
            ghl_request("PUT", f"/contacts/{record_id}", token,
                        json_body={"customFields": payload})
        else:
            ghl_request("PUT", f"/opportunities/{record_id}", token,
                        json_body={"customFields": payload})
        return "corregido" if any_wrong else "sin_cambio"
    except Exception as exc:
        err_msg = f"    ERROR actualizando {object_type} {record_id}: {exc}"
        if log_lock:
            with log_lock:
                print(err_msg)
        else:
            print(err_msg)
        return "error"


def execute_plans_parallel(plans, object_type, token, location_id, run_id,
                            dry_run, quiet):
    """Ejecuta los plans en paralelo dentro de la misma location.
    El rate limit por token sigue siendo respetado por _wait()."""
    import threading
    stats = {}
    log_lock = threading.Lock()

    def _runner(plan):
        if not script_audit.wait_if_paused_or_stopped(run_id):
            return "stopped"
        return apply_correction(plan=plan, object_type=object_type,
                                token=token, location_id=location_id,
                                run_id=run_id, dry_run=dry_run, quiet=quiet,
                                log_lock=log_lock)

    workers = 1 if dry_run else INTRA_LOCATION_PUT_WORKERS
    if workers <= 1 or len(plans) <= 1:
        for plan in plans:
            res = _runner(plan)
            if res == "stopped":
                break
            stats[res] = stats.get(res, 0) + 1
        return stats

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = [ex.submit(_runner, p) for p in plans]
        for fut in concurrent.futures.as_completed(futures):
            try:
                res = fut.result()
            except Exception as exc:
                with log_lock:
                    print(f"    ERROR worker {object_type}: {exc}")
                res = "error"
            if res == "stopped":
                continue
            stats[res] = stats.get(res, 0) + 1
    return stats

# ---------------------------------------------------------------------------
# Procesamiento por tipo
# ---------------------------------------------------------------------------

def process_records(*, account, entry, object_type, sucursal_field_id,
                     tienda_field_id, dry_run, run_id, fresh, quiet):
    """Pipeline comun para contactos y oportunidades."""
    location_id = account["location_id"]
    token       = account["token"]
    sucursal_val = entry["sucursal"]
    tienda_val   = entry["tienda"]

    if not sucursal_field_id and not tienda_field_id:
        print(f"  SKIP {object_type}: campos Sucursal y TIENDA no existen en el schema.")
        return {}

    missing = []
    if not sucursal_field_id: missing.append(FIELD_SUCURSAL)
    if not tienda_field_id:   missing.append(FIELD_TIENDA)
    if missing:
        print(f"  WARN {object_type}: campo(s) no encontrado(s) en schema: {missing}")

    plural = "Contactos" if object_type == "contact" else "Oportunidades"

    # 1. Listado base (local o fresh)
    if fresh:
        if object_type == "contact":
            base = get_all_contacts_api(location_id, token)
        else:
            base = get_all_opportunities_api(location_id, token)
        print(f"  {plural} desde API: {len(base)}")
    else:
        if object_type == "contact":
            base = load_contacts_local(location_id)
        else:
            base = load_opportunities_local(location_id)
        print(f"  {plural} desde cache local: {len(base)}")

    # 2. En modo fresh el listado no trae todos los campos -> GET por registro
    if fresh:
        full_records = []
        for summary in base:
            if not script_audit.wait_if_paused_or_stopped(run_id):
                break
            rid = summary.get("id")
            if not rid:
                continue
            try:
                if object_type == "contact":
                    full = get_contact_full_api(location_id, rid, token)
                else:
                    full = get_opportunity_full_api(location_id, rid, token)
                full_records.append(full)
            except Exception as exc:
                print(f"    ERROR GET {object_type} {rid}: {exc}")
        records = full_records
    else:
        records = base

    # 3. Plan de correcciones (puro CPU + JSON, sin IO)
    plans = plan_corrections(records, sucursal_field_id, tienda_field_id,
                              sucursal_val, tienda_val)

    needs_fix = sum(1 for p in plans if p["any_wrong"])
    confirms  = len(plans) - needs_fix
    print(f"  Plan {object_type}: {needs_fix} con correccion / {confirms} ya correctos")

    if not plans:
        return {}

    # 4. En dry-run NO filtramos: queremos ver tambien los [confirmar].
    #    En apply solo ejecutamos los que cambian, para reducir API calls.
    if dry_run:
        plans_to_run = plans
    else:
        plans_to_run = [p for p in plans if p["any_wrong"]]
        if not plans_to_run:
            return {"sin_cambio": len(plans)}

    # 5. Ejecucion (paralela si aplica)
    stats = execute_plans_parallel(plans_to_run, object_type, token,
                                    location_id, run_id, dry_run, quiet)

    # 6. Contabilizar los "confirmar" silenciados en apply
    if not dry_run:
        silent_ok = len(plans) - len(plans_to_run)
        if silent_ok:
            stats["sin_cambio"] = stats.get("sin_cambio", 0) + silent_ok

    return stats

# ---------------------------------------------------------------------------
# Procesamiento por location
# ---------------------------------------------------------------------------

def process_location(account: dict, verifier_map: dict, args) -> dict:
    location_id = account["location_id"]
    token       = account["token"]
    name        = account["nombre"]
    dry_run     = not args.apply
    fresh       = bool(args.fresh)

    print(f"\n{'=' * 72}")
    print(f"LOCATION: {name} ({location_id})")
    print(f"{'=' * 72}")

    entry = verifier_map.get(location_id)
    if not entry:
        print("  SKIP: location no encontrada en el verificador CSV.")
        return {}
    print(f"  CSV -> SC: '{entry['sc_name']}'  |  Sucursal: '{entry['sucursal']}'  |  TIENDA: '{entry['tienda']}'")

    # Chequeo de frescura del cache (solo en modo local)
    if not fresh:
        fresh_info = get_cache_freshness(location_id)
        hours_old = fresh_info["hours_old"]
        if hours_old is None:
            print("  WARN: no hay datos en SQLite para esta location.")
            if not args.force_stale:
                print("  SKIP: corre una sincronizacion primero o usa --fresh / --force-stale.")
                return {"sin_cambio": 0, "_skipped_stale": 1}
        else:
            print(f"  Cache: synced_at mas antiguo = {fresh_info['oldest']}  ({hours_old:.1f}h)")
            if hours_old > STALE_CACHE_HOURS and not args.force_stale:
                print(f"  SKIP: cache mayor a {STALE_CACHE_HOURS}h. "
                      f"Sincroniza o usa --force-stale para ignorar.")
                return {"sin_cambio": 0, "_skipped_stale": 1}

    # Resolver field IDs (local primero salvo --fresh)
    prefer_local = not fresh
    try:
        c_ids = resolve_field_ids(location_id, token, "contact", prefer_local)
    except Exception as exc:
        print(f"  WARN no se pudo obtener schema de contactos: {exc}")
        c_ids = {}
    try:
        o_ids = resolve_field_ids(location_id, token, "opportunity", prefer_local)
    except Exception as exc:
        print(f"  WARN no se pudo obtener schema de oportunidades: {exc}")
        o_ids = {}

    c_sucursal_id = c_ids.get(FIELD_SUCURSAL)
    c_tienda_id   = c_ids.get(FIELD_TIENDA)
    o_sucursal_id = o_ids.get(FIELD_SUCURSAL)
    o_tienda_id   = o_ids.get(FIELD_TIENDA)

    print(f"  Schema contacto    -> '{FIELD_SUCURSAL}': {c_sucursal_id or 'NO ENCONTRADO'}  "
          f"'{FIELD_TIENDA}': {c_tienda_id or 'NO ENCONTRADO'}")
    print(f"  Schema oportunidad -> '{FIELD_SUCURSAL}': {o_sucursal_id or 'NO ENCONTRADO'}  "
          f"'{FIELD_TIENDA}': {o_tienda_id or 'NO ENCONTRADO'}")

    grand = {}
    quiet = getattr(args, "quiet", False)

    # Contactos
    if not args.opportunities_only:
        print(f"\n  [CONTACTOS]")
        try:
            stats = process_records(
                account=account, entry=entry, object_type="contact",
                sucursal_field_id=c_sucursal_id, tienda_field_id=c_tienda_id,
                dry_run=dry_run, run_id=args.run_id, fresh=fresh, quiet=quiet,
            )
            print(f"  Stats contactos: {stats}")
            for k, v in stats.items():
                grand[k] = grand.get(k, 0) + v
        except Exception as exc:
            print(f"  ERROR bloque contactos: {exc}")
            grand["error"] = grand.get("error", 0) + 1

    # Oportunidades
    if not args.contacts_only:
        print(f"\n  [OPORTUNIDADES]")
        try:
            stats = process_records(
                account=account, entry=entry, object_type="opportunity",
                sucursal_field_id=o_sucursal_id, tienda_field_id=o_tienda_id,
                dry_run=dry_run, run_id=args.run_id, fresh=fresh, quiet=quiet,
            )
            print(f"  Stats oportunidades: {stats}")
            for k, v in stats.items():
                grand[k] = grand.get(k, 0) + v
        except Exception as exc:
            print(f"  ERROR bloque oportunidades: {exc}")
            grand["error"] = grand.get("error", 0) + 1

    return grand

# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def select_locations(args) -> list:
    accounts = sync_engine.parse_accounts_csv()
    if args.location:
        matches = [a for a in accounts if a["location_id"] == args.location]
        if not matches:
            raise SystemExit(f"Location '{args.location}' no encontrada en el CSV de mesa de control.")
        return matches
    if args.all:
        if args.include_main:
            return list(accounts)
        return [a for a in accounts if a.get("type") == "branch"]
    raise SystemExit(
        "Especifica --location <id> o --all.\n"
        "Por seguridad el script corre en DRY-RUN a menos que agregues --apply."
    )


def main():
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")

    parser = argparse.ArgumentParser(
        description="Llena campos 'Sucursal' y 'TIENDA' en contactos y oportunidades "
                    "usando el Verificador CSV. Por defecto consulta SQLite local "
                    "para decidir que registros corregir y solo entonces hace PUT a la API."
    )
    parser.add_argument("--location",           help="Location ID especifico a procesar.")
    parser.add_argument("--all",                action="store_true",
                        help="Procesa todas las sucursales del CSV de mesa de control.")
    parser.add_argument("--include-main",       action="store_true",
                        help="Incluye la cuenta principal (Marca) cuando se usa --all.")
    parser.add_argument("--apply",              action="store_true",
                        help="Aplica cambios reales en GHL. Sin este flag corre en DRY-RUN.")
    parser.add_argument("--contacts-only",      action="store_true",
                        help="Solo procesa contactos (omite oportunidades).")
    parser.add_argument("--opportunities-only", action="store_true",
                        help="Solo procesa oportunidades (omite contactos).")
    parser.add_argument("--fresh",              action="store_true",
                        help="Modo en vivo: ignora SQLite y consulta cada contacto/oportunidad "
                             "directamente a la API GHL. Mas lento pero garantiza datos al instante.")
    parser.add_argument("--force-stale",        action="store_true",
                        help="Ignora el chequeo de antiguedad del cache local (>24h). "
                             "Sin este flag, el script aborta si el cache esta desactualizado.")
    parser.add_argument("--run-id",             help="Audit run ID suministrado por dashboard.")
    parser.add_argument("--quiet",              action="store_true",
                        help="Oculta los registros donde el valor ya era correcto.")
    args = parser.parse_args()

    if args.contacts_only and args.opportunities_only:
        raise SystemExit("No se puede combinar --contacts-only con --opportunities-only.")

    verifier_map = load_verifier_map()
    locations    = select_locations(args)
    dry_run      = not args.apply
    mode_label   = "FRESH (API en vivo)" if args.fresh else "LOCAL (cache SQLite)"

    print("\n" + "=" * 72)
    print("FILL SUCURSAL + TIENDA DESDE VERIFICADOR CSV")
    print("=" * 72)
    print(f"  Modo lectura: {mode_label}")
    print(f"  Modo escritura: {'DRY-RUN (ningun cambio en GHL)' if dry_run else 'APPLY (escritura real)'}")
    print(f"  Locations:    {len(locations)}")
    print(f"  Campos:       '{FIELD_SUCURSAL}' y '{FIELD_TIENDA}' en contactos y oportunidades")
    print(f"  Fuente CSV:   {VERIFIER_FILENAME}")
    if args.force_stale and not args.fresh:
        print("  Cache: --force-stale activo (se ignora el chequeo de antiguedad).")
    if dry_run:
        print("\n  [DRY-RUN] Solo se mostraran los cambios planeados.")
        print("  Usa --apply cuando quieras ejecutar los cambios reales.\n")

    grand_total: dict = {}
    for account in locations:
        stats = process_location(account, verifier_map, args)
        for k, v in stats.items():
            grand_total[k] = grand_total.get(k, 0) + v

    print("\n" + "=" * 72)
    print(f"RESUMEN GLOBAL: {grand_total}")
    if dry_run:
        print("\nDry-run completado. Usa --apply para aplicar los cambios al CRM.")
    print("=" * 72 + "\n")

    if grand_total.get("error"):
        raise SystemExit(1)


if __name__ == "__main__":
    main()
