#!/usr/bin/env python3
"""Aplica un LAYOUT explícito (folder + orden) a los custom fields de cada cuenta.

A diferencia de align_custom_field_order.py (que usa Marca como referencia), aquí
el orden lo define una tabla declarativa `LAYOUT` editable abajo: por cada objeto
(contact/opportunity) y folder, la lista ordenada de campos por NOMBRE.

Para cada campo listado se asigna:
  - parentId = el folder destino (resuelto por NOMBRE en cada cuenta), moviéndolo
    de folder si hace falta.
  - position = posición incremental según su orden en la lista (100, 200, 300…).

Reglas:
  - Los campos NO listados en el LAYOUT no se tocan (quedan en su folder/posición).
  - Match por nombre normalizado (case + acentos + espacios insensitive).
  - Campos estándar (First Name, Phone, Email…) NO los expone la API → no se tocan.
  - Si un campo o folder del LAYOUT no existe en la cuenta, se reporta y se omite.

Verificado contra la API V2 de GHL: `PUT /locations/{loc}/customFields/{id}` con
`{"parentId": ..., "position": N}` aplica ambos (la `position` sola es ignorada).
Cambio puramente organizacional (UI): no afecta valores, workflows ni formularios.

Uso:
    python scripts/apply_custom_field_layout.py --location <id>            # dry-run
    python scripts/apply_custom_field_layout.py --location <id> --apply --run-id <uuid>
    python scripts/apply_custom_field_layout.py --all --apply --run-id <uuid>
"""

import argparse
import datetime
import json
import os
import sys
import time
import unicodedata
import warnings

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 sync_engine  # noqa: E402
import script_audit  # noqa: E402
from paths import MIGRATIONS_DIR  # noqa: E402

BASE_URL = "https://services.leadconnectorhq.com"
API_VERSION = "2021-07-28"
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
DEMO_LOCATION_IDS = ["Vf7qQl3L9vakJ8hDtQ8e", "Z64WQKORPVwXb5mn68Ef"]
POSITION_STEP = 100  # separación entre campos consecutivos dentro de un folder

# ───────────────────────────────────────────────────────────────────────────
# LAYOUT declarativo — editar aquí el orden deseado.
# Estructura: { object_key: [ (folder_name, [field_name_en_orden, ...]), ... ] }
# Solo se tocan los campos listados. Los demás quedan intactos.
# ───────────────────────────────────────────────────────────────────────────

LAYOUT = {
    "opportunity": [
        ("Opportunity Details", [
            "Persona que atendió al prospecto",
            "Vehículo",
            "Modalidad de Empeño",
            "Fuente de Prospecto",
            "Visita a Sucursal",
            "Fecha de ultima visita a sucursal",
            "Lead Descartado",
            "CANAL DE ORIGEN",
            "Sucursal",
            "TIENDA",
            "ID Oportunidad Sucursal",
        ]),
    ],
    "contact": [
        ("Contact", [
            "Marca del Vehículo",
            "Versión del Vehículo",
            "Año del Vehículo",
            "¿Qué modalidad prefieres?",
            "TIENDA",
        ]),
        # Campos sacados del folder "Contact" hacia "Additional Info" (después de
        # los que ya viven ahí). Se listan los existentes para fijar el orden.
        ("Additional Info", [
            "Descripción",
            "Presupuesto",
            "Archivos Adicionales",
            "Sucursal",
            "Información Adicional",
            "Fuente de Prospecto",
            "ID Contacto Monte Providencia",
            "ID Contacto Sucursal",
        ]),
    ],
}

_last_request_by_token: dict = {}


def normalize_name(value):
    s = str(value or "").strip().lower()
    s = unicodedata.normalize("NFKD", s)
    s = "".join(c for c in s if not unicodedata.combining(c))
    return " ".join(s.split())


def wait_for_rate_limit(token):
    elapsed = time.time() - _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, endpoint, token, *, params=None, json_body=None,
                version=API_VERSION, retries=3):
    wait_for_rate_limit(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",
    }
    last_exc = None
    for attempt in range(retries):
        try:
            resp = requests.request(method, url, headers=headers, params=params,
                                    json=json_body, timeout=30)
        except requests.RequestException as exc:
            last_exc = exc
            time.sleep(0.5 * (attempt + 1))
            continue
        if resp.status_code == 429:
            time.sleep(1.0 * (attempt + 1))
            continue
        if resp.status_code >= 500:
            time.sleep(1.0 * (attempt + 1))
            continue
        resp.raise_for_status()
        if resp.status_code == 204:
            return {}
        return resp.json()
    if last_exc:
        raise last_exc


def list_fields_and_folders(account, model):
    data = ghl_request("GET", f"/locations/{account['location_id']}/customFields",
                       account["token"], params={"model": model})
    items = data.get("customFields") or []
    fields = [f for f in items if f.get("documentType") != "folder"]
    parent_ids = {f.get("parentId") for f in fields if f.get("parentId")}
    folders_by_id = {}
    for pid in parent_ids:
        try:
            d = ghl_request("GET", f"/locations/{account['location_id']}/customFields/{pid}",
                            account["token"])
            cf = d.get("customField") or d
            if cf.get("documentType") == "folder":
                folders_by_id[pid] = cf
        except Exception as exc:
            print(f"  ⚠ No pude leer folder {pid}: {exc}")
    return fields, folders_by_id


def plan_account_model(account, model, folder_specs):
    """folder_specs: lista [(folder_name, [field_name,...]), ...]."""
    actions = []
    fields, folders_by_id = list_fields_and_folders(account, model)
    field_by_norm = {}
    for f in fields:
        field_by_norm.setdefault(normalize_name(f.get("name")), f)
    folder_id_by_norm = {normalize_name(fo.get("name")): pid
                         for pid, fo in folders_by_id.items()}

    # Posición máxima de los campos ESTÁNDAR por folder. Los estándar (First
    # Name, Phone, Email, Pipeline, Stage…) no los expone el endpoint legacy de
    # customFields, pero SÍ el schema V2, y comparten folder y position con los
    # custom. Colocamos los custom DESPUÉS del último estándar del folder para
    # que no queden intercalados (p.ej. Marca del Vehículo debajo de Phone).
    std_max_by_pid = {}
    try:
        v2 = sync_engine.ghl_client.get_object_schema_fields(
            account["token"], account["location_id"], model)
        for f in v2:
            if f.get("dataType") == "STANDARD_FIELD" or f.get("standard"):
                pid = f.get("parentId")
                pos = f.get("position") or 0
                if pid and pos > std_max_by_pid.get(pid, -1):
                    std_max_by_pid[pid] = pos
    except Exception as exc:
        print(f"  ⚠ No pude leer schema V2 (posiciones estándar): {exc}")

    for folder_name, field_names in folder_specs:
        target_pid = folder_id_by_norm.get(normalize_name(folder_name))
        if not target_pid:
            actions.append({
                "status": "folder_missing", "object": model,
                "folder": folder_name,
                "details": f"Folder {folder_name!r} no existe en la cuenta.",
            })
            continue
        base = std_max_by_pid.get(target_pid, 0)
        for idx, fname in enumerate(field_names):
            target_pos = base + (idx + 1) * POSITION_STEP
            f = field_by_norm.get(normalize_name(fname))
            if not f:
                actions.append({
                    "status": "field_missing", "object": model,
                    "folder": folder_name, "field_name": fname,
                    "details": f"Campo {fname!r} no existe en {model}.",
                })
                continue
            cur_pid = f.get("parentId")
            cur_pos = f.get("position")
            cur_folder = (folders_by_id.get(cur_pid) or {}).get("name")
            if cur_pid == target_pid and cur_pos == target_pos:
                actions.append({
                    "status": "already_ok", "object": model,
                    "field_name": f.get("name"), "folder": folder_name,
                })
                continue
            actions.append({
                "status": "to_set", "object": model,
                "field_id": f.get("id"), "field_name": f.get("name"),
                "from": {"folder": cur_folder, "parentId": cur_pid, "position": cur_pos},
                "to": {"folder": folder_name, "parentId": target_pid, "position": target_pos},
            })
    return actions


def apply_actions(account, actions, *, dry_run, run_id):
    stats = {"set": 0, "skipped": 0, "errors": []}
    snap = {
        "account": account["nombre"], "location_id": account["location_id"],
        "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
        "dry_run": dry_run, "actions": actions,
    }
    os.makedirs(MIGRATIONS_DIR, exist_ok=True)
    ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    safe = "".join(c if c.isalnum() else "_" for c in account["nombre"])[:40]
    snap_path = os.path.join(MIGRATIONS_DIR, f"layout_{safe}_{ts}.json")
    with open(snap_path, "w", encoding="utf-8") as fh:
        json.dump(snap, fh, ensure_ascii=False, indent=2, default=str)

    for a in actions:
        if a["status"] != "to_set":
            stats["skipped"] += 1
            continue
        if dry_run:
            continue
        change_id = None
        if run_id:
            change_id = script_audit.record_change(
                run_id, account["location_id"], "__schema__",
                a["field_id"], a["field_id"], a["field_name"],
                {"parentId": a["from"]["parentId"], "position": a["from"]["position"]},
                {"parentId": a["to"]["parentId"], "position": a["to"]["position"]},
            )
        try:
            ghl_request("PUT",
                        f"/locations/{account['location_id']}/customFields/{a['field_id']}",
                        account["token"],
                        json_body={"parentId": a["to"]["parentId"],
                                   "position": a["to"]["position"]})
            if change_id:
                script_audit.mark_change(change_id, "applied")
            stats["set"] += 1
        except Exception as exc:
            if change_id:
                script_audit.mark_change(change_id, "failed", str(exc))
            stats["errors"].append({"field_id": a["field_id"], "error": str(exc)})

    return stats, snap_path


def print_plan(account, actions):
    to_set = [a for a in actions if a["status"] == "to_set"]
    ok = sum(1 for a in actions if a["status"] == "already_ok")
    miss = [a for a in actions if a["status"] in ("field_missing", "folder_missing")]
    print(f"\n[Plan] {account['nombre']} ({account['location_id']})")
    print(f"  ya ok={ok}  a aplicar={len(to_set)}  faltantes={len(miss)}")
    for a in to_set:
        fr, to = a["from"], a["to"]
        moved = "" if fr["folder"] == to["folder"] else f"  [MUEVE folder {fr['folder']!r} → {to['folder']!r}]"
        print(f"  ▸ {a['object']}.{a['field_name']!r}  pos {fr['position']}→{to['position']} en {to['folder']!r}{moved}")
    for a in miss:
        print(f"  ✗ {a['status']}  {a.get('field_name') or a.get('folder')!r}: {a['details']}")


def select_targets(args, accounts):
    if args.location:
        m = [a for a in accounts if a["location_id"] == args.location]
        if not m:
            raise SystemExit(f"Location {args.location} no encontrada.")
        return m
    if args.demos:
        return [a for a in accounts if a["location_id"] in DEMO_LOCATION_IDS]
    if args.all_productive:
        return [a for a in accounts
                if a["location_id"] not in DEMO_LOCATION_IDS
                and a["location_id"] != BRAND_LOCATION_ID]
    if args.all:
        return [a for a in accounts if a["location_id"] != BRAND_LOCATION_ID]
    raise SystemExit("Especifica --location <id>, --demos, --all-productive o --all.")


def main():
    parser = argparse.ArgumentParser(
        description="Aplica un LAYOUT explícito de folder+orden a los custom fields.")
    parser.add_argument("--location", help="Procesar solo esta cuenta.")
    parser.add_argument("--demos", action="store_true", help="Solo las 2 cuentas DEMO.")
    parser.add_argument("--all-productive", dest="all_productive", action="store_true",
                        help="Todas las sucursales productivas (excluye Marca y DEMOs).")
    parser.add_argument("--all", action="store_true", help="Todas las sucursales (excluye Marca).")
    parser.add_argument("--object", choices=["contact", "opportunity", "both"], default="both",
                        help="Objeto a procesar. Default both.")
    parser.add_argument("--apply", action="store_true",
                        help="Ejecuta los cambios. Sin este flag corre en DRY-RUN.")
    parser.add_argument("--run-id", help="ID para registrar en script_audit (rollback).")
    args = parser.parse_args()

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

    dry_run = not args.apply
    models = ["contact", "opportunity"] if args.object == "both" else [args.object]
    accounts = sync_engine.parse_accounts_csv()
    targets = select_targets(args, accounts)

    if args.run_id:
        script_audit.create_run(
            args.run_id, "apply_custom_field_layout.py",
            arguments=" ".join(sys.argv[1:]),
            locations=[t["location_id"] for t in targets],
        )

    print(f"Modo: {'DRY-RUN' if dry_run else 'APPLY'}")
    print(f"Objetos: {', '.join(models)}")
    print(f"Cuentas en scope: {len(targets)}")
    for model in models:
        for folder_name, names in LAYOUT.get(model, []):
            print(f"  {model} / {folder_name}: {len(names)} campos")

    total_set = total_skipped = 0
    errors_global = []
    for acc in targets:
        if not script_audit.wait_if_paused_or_stopped(args.run_id):
            print("Detención solicitada. Saliendo.")
            break
        all_actions = []
        try:
            for model in models:
                if model in LAYOUT:
                    all_actions.extend(plan_account_model(acc, model, LAYOUT[model]))
        except Exception as exc:
            print(f"  [{acc['nombre']}] ERROR plan: {exc}")
            errors_global.append({"account": acc["nombre"], "error": str(exc)})
            continue
        print_plan(acc, all_actions)
        try:
            stats, snap_path = apply_actions(acc, all_actions, dry_run=dry_run, run_id=args.run_id)
            total_set += stats["set"]
            total_skipped += stats["skipped"]
            errors_global.extend(stats["errors"])
            print(f"  snapshot: {snap_path}")
            if not dry_run:
                print(f"  ✓ set={stats['set']}  skipped={stats['skipped']}  errors={len(stats['errors'])}")
        except Exception as exc:
            print(f"  ✗ apply falló: {exc}")
            errors_global.append({"account": acc["nombre"], "error": str(exc)})

    print("\n" + "=" * 60)
    if dry_run:
        print("DRY-RUN — no se modificó nada. Revisa el plan y vuelve a correr con --apply.")
    print(f"Cuentas procesadas : {len(targets)}")
    print(f"Campos colocados   : {total_set}")
    print(f"Acciones saltadas  : {total_skipped}")
    print(f"Errores            : {len(errors_global)}")
    for e in errors_global[:20]:
        print(f"   - {e}")


if __name__ == "__main__":
    main()
