#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Deduplica pipelines repetidos en sucursales MP y mueve las oportunidades
del pipeline mas antiguo (por fecha de actualizacion) al mas reciente.

Flujo:
  1. Lista pipelines por sucursal (live API o SQLite local).
  2. Agrupa pipelines duplicados por nombre normalizado dentro de cada sucursal.
  3. Elige como "oficial" el pipeline con dateUpdated mas reciente (fallback dateAdded).
  4. Mapea etapas obsoleto -> oficial por nombre normalizado; si todas matchean usa
     ese mapeo, si no intenta por posicion.
  5. Lista las oportunidades que apuntan al pipeline obsoleto via
     POST /opportunities/search (paginacion completa).
  6. En --apply llama PUT /opportunities/{id} con pipelineId + pipelineStageId
     hacia el pipeline oficial.

La cuenta principal (marca) se ignora siempre: solo se procesan sucursales.

Default es DRY-RUN. Para aplicar usa --apply junto con --yes (TTY o dashboard).
"""

import argparse
import json
import sys
from collections import defaultdict
from datetime import datetime, timezone

from common import BRAND_LOCATION_ID, normalize_name, select_accounts
import db
import script_audit
import sync_engine

SCRIPT_NAME = "dedupe_branch_pipelines.py"


def parse_datetime(value):
    if not value:
        return None
    text = str(value).strip()
    try:
        if text.endswith("Z"):
            text = text[:-1] + "+00:00"
        return datetime.fromisoformat(text).astimezone(timezone.utc)
    except ValueError:
        return None


def display_date(value):
    parsed = parse_datetime(value)
    if not parsed:
        return value or "Sin fecha"
    return parsed.strftime("%Y-%m-%d %H:%M UTC")


def clean_label(value):
    return " ".join(str(value or "").strip().split())


def stage_name(stage):
    return clean_label(stage.get("name") or stage.get("title") or stage.get("id") or "Sin nombre")


def stage_order(stage, index):
    value = stage.get("order")
    if value is None:
        value = stage.get("position")
    try:
        return int(value)
    except (TypeError, ValueError):
        return index


def normalize_stage(stage, index):
    name = stage_name(stage)
    return {
        "id": stage.get("id"),
        "name": name,
        "norm_name": normalize_name(name),
        "order": stage_order(stage, index),
    }


def normalize_pipeline(pipeline):
    name = clean_label(pipeline.get("name") or pipeline.get("id") or "Sin nombre")
    raw_stages = pipeline.get("stages") or []
    stages = [normalize_stage(stage, idx) for idx, stage in enumerate(raw_stages)]
    stages.sort(key=lambda item: (item["order"], item["norm_name"]))
    return {
        "id": pipeline.get("id"),
        "name": name,
        "norm_name": normalize_name(name),
        "stages": stages,
        "date_added": pipeline.get("date_added") or pipeline.get("dateAdded") or pipeline.get("createdAt"),
        "date_updated": pipeline.get("date_updated") or pipeline.get("dateUpdated") or pipeline.get("updatedAt"),
    }


def pipeline_sort_date(pipeline):
    return (
        parse_datetime(pipeline.get("date_updated"))
        or parse_datetime(pipeline.get("date_added"))
        or datetime.min.replace(tzinfo=timezone.utc)
    )


def load_pipelines(account, live, client):
    if live:
        raw = client.get_pipelines(account["token"], account["location_id"])
    else:
        raw = db.get_pipelines(account["location_id"])
    return [normalize_pipeline(pipeline) for pipeline in raw]


def group_duplicates(pipelines):
    groups = defaultdict(list)
    for pipeline in pipelines:
        groups[pipeline["norm_name"]].append(pipeline)
    return {name: items for name, items in groups.items() if len(items) > 1}


def stage_map_by_name(obsolete, official):
    name_to_official = {stage["norm_name"]: stage for stage in official["stages"]}
    mapping = []
    for old_stage in obsolete["stages"]:
        new_stage = name_to_official.get(old_stage["norm_name"])
        if not new_stage:
            return None
        mapping.append((old_stage, new_stage))
    return mapping


def stage_map_by_position(obsolete, official):
    if len(obsolete["stages"]) != len(official["stages"]):
        return None
    return list(zip(obsolete["stages"], official["stages"]))


def resolve_stage_mapping(obsolete, official):
    mapping = stage_map_by_name(obsolete, official)
    if mapping:
        return mapping, "por nombre"
    mapping = stage_map_by_position(obsolete, official)
    if mapping:
        return mapping, "por posicion"
    return None, "imposible"


def fetch_opportunities_live(token, location_id, pipeline_id, client):
    opportunities = []
    page = 1
    limit = 100
    while True:
        data = client.search_opportunities(token, location_id, limit=limit, page=page, pipeline_id=pipeline_id)
        batch = data.get("opportunities", []) if isinstance(data, dict) else []
        if not batch:
            break
        opportunities.extend(batch)
        total = data.get("total", 0) or 0
        if len(batch) < limit or (total > 0 and len(opportunities) >= total):
            break
        page += 1
    return opportunities


def fetch_opportunities_sqlite(location_id, pipeline_id):
    rows = db.get_opportunities(location_id, pipeline_id=pipeline_id)
    out = []
    for row in rows:
        out.append({
            "id": row.get("id"),
            "name": row.get("name"),
            "pipelineStageId": row.get("pipeline_stage_id"),
            "pipelineId": row.get("pipeline_id"),
            "contactId": row.get("contact_id"),
        })
    return out


def index_opportunities_by_contact(opps):
    """Indexa una lista de opps por contactId. Si un contacto tiene varias opps en
    el mismo pipeline (caso raro pero posible si GHL lo permite), se queda la primera
    encontrada — el resto se ignora porque el merge solo decide entre 2 a la vez."""
    out = {}
    for opp in opps or []:
        cid = opp.get("contactId") or opp.get("contact_id")
        if cid and cid not in out:
            out[cid] = opp
    return out


def stage_order_in_pipeline(pipeline, stage_id):
    """Devuelve el order/posicion de stage_id dentro de los stages normalizados
    del pipeline. None si no existe. Stages ya vienen ordenados por order ascendente."""
    for stage in pipeline.get("stages") or []:
        if stage.get("id") == stage_id:
            return stage.get("order")
    return None


def merge_custom_fields(advanced_cfs, less_advanced_cfs):
    """Custom fields ganadores: los de la opp en etapa más avanzada. Los huecos
    (custom fields que existen en la otra opp pero no en la avanzada) se rellenan.
    Cada cf tiene shape GHL: {id, fieldValue|value, ...}. No mergeamos por nombre,
    solo por id, porque GHL identifica por id."""
    def cf_id(cf):
        return cf.get("id") or cf.get("customFieldId") or cf.get("field_id")
    merged = {}
    for cf in advanced_cfs or []:
        cid = cf_id(cf)
        if cid:
            merged[cid] = cf
    for cf in less_advanced_cfs or []:
        cid = cf_id(cf)
        if cid and cid not in merged:
            merged[cid] = cf
    return list(merged.values())


def _annotate_with_merge_analysis(plan, official, obsolete, mapping, live, client, account):
    """Para cada opp del obsoleto, decide si entra al flujo de merge (porque el
    contacto ya tiene otra opp en el pipeline oficial) y precomputa quién gana.

    Agrega al plan:
      - `official_opps_by_contact`: dict para lookup rápido durante apply.
      - `merge_analysis`: lista paralela a opportunities. Cada item es {
            "opp_id": ...,
            "contact_id": ...,
            "is_duplicate": bool,
            "winner": "obsolete" | "official" | None,   # None si no hay duplicado
            "official_opp_id": ... | None,
            "obsolete_stage_order": int | None,
            "official_stage_order": int | None,
        }
    """
    if not mapping:
        plan["official_opps_by_contact"] = {}
        plan["merge_analysis"] = []
        return

    # Cargar opps del pipeline oficial (live siempre que sea posible — los datos
    # locales pueden estar stale y el merge necesita la última verdad).
    if live:
        official_opps = fetch_opportunities_live(account["token"], account["location_id"], official["id"], client)
    else:
        official_opps = fetch_opportunities_sqlite(account["location_id"], official["id"])
    by_contact = index_opportunities_by_contact(official_opps)
    plan["official_opps_by_contact"] = by_contact

    # Mapeo de stage_id obsoleto → stage del oficial (objeto stage normalizado).
    stage_map = {old["id"]: new for old, new in mapping}

    analysis = []
    for opp in plan["opportunities"]:
        opp_id = opp.get("id")
        contact_id = opp.get("contactId") or opp.get("contact_id")
        existing = by_contact.get(contact_id) if contact_id else None
        if not existing:
            analysis.append({
                "opp_id": opp_id,
                "contact_id": contact_id,
                "is_duplicate": False,
                "winner": None,
                "official_opp_id": None,
                "obsolete_stage_order": None,
                "official_stage_order": None,
            })
            continue

        # El obsoleto se "mapearía" al stage X del oficial. El existente está en
        # stage Y del oficial. Ganador = el de mayor order (etapa más avanzada).
        current_stage_id = opp_stage_id(opp)
        mapped_stage = stage_map.get(current_stage_id)
        mapped_order = mapped_stage.get("order") if mapped_stage else None
        existing_stage_id = (existing.get("pipelineStageId") or
                             existing.get("pipeline_stage_id") or
                             existing.get("stageId"))
        existing_order = stage_order_in_pipeline(official, existing_stage_id)
        # Resolución de ties y nulos:
        #   - Si una de las dos no tiene order, gana la otra.
        #   - Si ambas tienen order, gana la mayor.
        #   - Tie: oficial gana (ya existe, evita escrituras innecesarias).
        if mapped_order is None and existing_order is None:
            winner = "official"
        elif mapped_order is None:
            winner = "official"
        elif existing_order is None:
            winner = "obsolete"
        elif mapped_order > existing_order:
            winner = "obsolete"
        else:
            winner = "official"
        analysis.append({
            "opp_id": opp_id,
            "contact_id": contact_id,
            "is_duplicate": True,
            "winner": winner,
            "official_opp_id": existing.get("id"),
            "obsolete_stage_order": mapped_order,
            "official_stage_order": existing_order,
        })
    plan["merge_analysis"] = analysis


def build_plan(account, pipelines, live, client):
    duplicate_groups = group_duplicates(pipelines)
    plans = []
    for group in duplicate_groups.values():
        ordered = sorted(group, key=pipeline_sort_date)
        official = ordered[-1]
        obsoletes = ordered[:-1]
        for obsolete in obsoletes:
            mapping, mapping_kind = resolve_stage_mapping(obsolete, official)
            if live:
                opps = fetch_opportunities_live(account["token"], account["location_id"], obsolete["id"], client)
            else:
                opps = fetch_opportunities_sqlite(account["location_id"], obsolete["id"])
            plan = {
                "obsolete": obsolete,
                "official": official,
                "mapping": mapping,
                "mapping_kind": mapping_kind,
                "opportunities": opps,
            }
            _annotate_with_merge_analysis(plan, official, obsolete, mapping, live, client, account)
            plans.append(plan)
    return plans


def build_plan_explicit(account, pipelines, from_id, to_id, live, client):
    by_id = {pipeline["id"]: pipeline for pipeline in pipelines}
    obsolete = by_id.get(from_id)
    official = by_id.get(to_id)
    if obsolete is None:
        raise SystemExit(f"--from-pipeline {from_id} no existe en la sucursal {account['location_id']}.")
    if official is None:
        raise SystemExit(f"--to-pipeline {to_id} no existe en la sucursal {account['location_id']}.")
    if from_id == to_id:
        raise SystemExit("--from-pipeline y --to-pipeline no pueden ser el mismo ID.")
    mapping, mapping_kind = resolve_stage_mapping(obsolete, official)
    if live:
        opps = fetch_opportunities_live(account["token"], account["location_id"], obsolete["id"], client)
    else:
        opps = fetch_opportunities_sqlite(account["location_id"], obsolete["id"])
    plan = {
        "obsolete": obsolete,
        "official": official,
        "mapping": mapping,
        "mapping_kind": mapping_kind,
        "opportunities": opps,
    }
    _annotate_with_merge_analysis(plan, official, obsolete, mapping, live, client, account)
    return [plan]


def opp_stage_id(opp):
    return (
        opp.get("pipelineStageId")
        or opp.get("pipeline_stage_id")
        or opp.get("stageId")
    )


def opp_label(opp):
    name = opp.get("name") or opp.get("contactName") or opp.get("contact_name")
    return name or opp.get("id") or "(sin nombre)"


def print_plan(account, plan):
    obsolete = plan["obsolete"]
    official = plan["official"]
    print(f"\n--- Sucursal: {account['nombre']} ({account['location_id']}) ---")
    print(
        f"Pipeline obsoleto: '{obsolete['name']}' ({obsolete['id']}) | "
        f"creado {display_date(obsolete.get('date_added'))} | "
        f"actualizado {display_date(obsolete.get('date_updated'))}"
    )
    print(
        f"Pipeline oficial : '{official['name']}' ({official['id']}) | "
        f"creado {display_date(official.get('date_added'))} | "
        f"actualizado {display_date(official.get('date_updated'))}"
    )
    print(f"Mapeo de etapas: {plan['mapping_kind']} | obsoleto {len(obsolete['stages'])} -> oficial {len(official['stages'])}")
    if plan["mapping"] is None:
        print("  ERROR: no se puede mapear etapas, plan ignorado.")
        return
    for idx, (old_stage, new_stage) in enumerate(plan["mapping"], 1):
        marker = "OK" if old_stage["norm_name"] == new_stage["norm_name"] else "POS"
        print(f"  {idx:>2}. [{marker}] {old_stage['name']} ({old_stage['id']}) -> {new_stage['name']} ({new_stage['id']})")
    print(f"Oportunidades en pipeline obsoleto: {len(plan['opportunities'])}")


DUPLICATE_OPP_HINTS = ("duplicate opportunity", "duplicate opportunity for the contact")


def is_duplicate_contact_error(exc):
    response = getattr(exc, "response", None)
    body = ""
    if response is not None:
        try:
            body = response.text or ""
        except Exception:
            body = ""
    haystack = (body + " " + str(exc)).lower()
    return any(hint in haystack for hint in DUPLICATE_OPP_HINTS)


def _execute_merge(opp, opp_id, current_stage_id, new_stage_id,
                   existing_official, winner, account, official, obsolete,
                   run_id, client):
    """Resuelve un duplicado mergeando custom fields y eliminando la opp obsoleta.

    `winner` ∈ {"obsolete", "official"}:
      - obsolete: la opp del pipeline obsoleto está en etapa más avanzada. PUT a
        la oficial con stage del obsoleto (post-mapeo) + custom fields ganadores
        de la obsoleta (rellenando huecos con los de la oficial). DELETE obsoleta.
      - official: la oficial ya está más avanzada. No tocamos sus campos. DELETE obsoleta.

    Devuelve True si todo OK, False si hubo error inesperado.
    """
    token = account["token"]
    official_opp_id = existing_official.get("id")
    obsolete_cfs = opp.get("customFields") or opp.get("custom_fields") or []
    official_cfs = existing_official.get("customFields") or existing_official.get("custom_fields") or []

    # PUT a la oficial cuando el obsoleto gana (cambia stage + custom fields).
    if winner == "obsolete":
        payload = {
            "pipelineId": official["id"],
            "pipelineStageId": new_stage_id,
            "customFields": merge_custom_fields(obsolete_cfs, official_cfs),
        }
        try:
            client.update_opportunity(token, official_opp_id, payload)
        except Exception as exc:
            print(f"  MERR {opp_id} {opp_label(opp)} | merge PUT a oficial fallo: {exc}")
            if run_id:
                script_audit.record_change(
                    run_id, account["location_id"], "opportunity_merge_put_failed",
                    official_opp_id, official["id"], "merge_put",
                    {"opp_id": opp_id, "winner": winner}, {"error": str(exc)},
                )
            return False

    # DELETE de la obsoleta (siempre, ganase quien ganase).
    try:
        client.delete_opportunity(token, opp_id, account["location_id"])
    except Exception as exc:
        print(f"  MERR {opp_id} {opp_label(opp)} | DELETE obsoleta fallo: {exc}")
        if run_id:
            script_audit.record_change(
                run_id, account["location_id"], "opportunity_merge_delete_failed",
                opp_id, obsolete["id"], "merge_delete",
                {"winner": winner, "official_opp_id": official_opp_id}, {"error": str(exc)},
            )
        return False

    # Audit de la operación completa.
    if run_id:
        try:
            change_id = script_audit.record_change(
                run_id,
                account["location_id"],
                "opportunity_merged_and_deleted",
                opp_id,
                obsolete["id"],
                "merge",
                {"obsolete_opp_id": opp_id, "obsolete_pipeline_id": obsolete["id"],
                 "obsolete_stage_id": current_stage_id},
                {"official_opp_id": official_opp_id, "official_pipeline_id": official["id"],
                 "winner": winner, "winning_stage_id": new_stage_id if winner == "obsolete" else existing_official.get("pipelineStageId")},
            )
            if change_id is not None:
                script_audit.mark_change(change_id, "applied")
        except Exception:
            pass
    return True


def apply_plan(account, plan, run_id, max_opps, client):
    obsolete = plan["obsolete"]
    official = plan["official"]
    mapping = plan["mapping"]
    if mapping is None:
        return {"moved": 0, "skipped": 0, "duplicates": 0, "failed": 0,
                "merged_obsolete_won": 0, "merged_official_won": 0,
                "duplicate_records": []}

    stage_id_map = {old_stage["id"]: new_stage["id"] for old_stage, new_stage in mapping}
    opportunities = plan["opportunities"]
    if max_opps is not None:
        opportunities = opportunities[:max_opps]

    # Lookup precomputado: contact_id → opp existente en pipeline oficial.
    official_opps_by_contact = plan.get("official_opps_by_contact") or {}
    analysis_by_opp_id = {a["opp_id"]: a for a in (plan.get("merge_analysis") or [])}

    moved = 0
    skipped = 0
    duplicates = 0  # legacy: errores GHL inesperados que no se pudieron mergear
    failed = 0
    merged_obsolete_won = 0
    merged_official_won = 0
    duplicate_records = []
    token = account["token"]

    for opp in opportunities:
        opp_id = opp.get("id")
        if not opp_id:
            skipped += 1
            continue
        current_stage_id = opp_stage_id(opp)
        new_stage_id = stage_id_map.get(current_stage_id)
        if not new_stage_id:
            print(f"  SKIP {opp_id} {opp_label(opp)} | etapa actual {current_stage_id} sin mapeo")
            skipped += 1
            continue

        if run_id and not script_audit.wait_if_paused_or_stopped(run_id):
            print("  Detenido por control de ejecucion.")
            break

        analysis = analysis_by_opp_id.get(opp_id)
        contact_id = opp.get("contactId") or opp.get("contact_id")
        existing_official = official_opps_by_contact.get(contact_id) if contact_id else None

        # FLUJO MERGE: el contacto ya tiene opp en el pipeline oficial.
        if existing_official and analysis and analysis.get("is_duplicate"):
            winner = analysis.get("winner") or "official"
            ok = _execute_merge(opp, opp_id, current_stage_id, new_stage_id,
                                existing_official, winner, account, official, obsolete,
                                run_id, client)
            if not ok:
                failed += 1
                continue
            if winner == "obsolete":
                merged_obsolete_won += 1
                print(f"  MRGO {opp_id} {opp_label(opp)} | merge: obsoleto gana (datos copiados, obsoleta eliminada)")
            else:
                merged_official_won += 1
                print(f"  MRGF {opp_id} {opp_label(opp)} | merge: oficial gana (obsoleta eliminada)")
            continue

        # FLUJO NORMAL: PUT directo al pipeline oficial.
        change_id = None
        if run_id:
            change_id = script_audit.record_change(
                run_id,
                account["location_id"],
                "opportunity_pipeline",
                opp_id,
                obsolete["id"],
                "pipeline+stage",
                {"pipelineId": obsolete["id"], "pipelineStageId": current_stage_id},
                {"pipelineId": official["id"], "pipelineStageId": new_stage_id},
            )

        try:
            client.update_opportunity(token, opp_id, {
                "pipelineId": official["id"],
                "pipelineStageId": new_stage_id,
            })
            moved += 1
            if change_id is not None:
                script_audit.mark_change(change_id, "applied")
            print(f"  OK   {opp_id} {opp_label(opp)} -> {official['id']} / {new_stage_id}")
        except Exception as exc:
            if is_duplicate_contact_error(exc):
                # Race condition: el merge analysis no lo detectó (quizá la opp
                # oficial se creó después de cargar el plan). Fallback: marcamos
                # como duplicate sin mergear y reportamos para revisión manual.
                duplicates += 1
                duplicate_records.append({
                    "location_id": account["location_id"],
                    "location_name": account["nombre"],
                    "opp_id": opp_id,
                    "opp_name": opp_label(opp),
                    "obsolete_pipeline_id": obsolete["id"],
                })
                if change_id is not None:
                    script_audit.mark_change(change_id, "skipped_duplicate",
                                              "Duplicado detectado en GHL pero no en merge_analysis (race condition).")
                print(f"  DUP  {opp_id} {opp_label(opp)} | duplicado detectado por GHL post-plan; requiere revisión manual")
            else:
                failed += 1
                if change_id is not None:
                    script_audit.mark_change(change_id, "failed", str(exc))
                print(f"  ERR  {opp_id} {opp_label(opp)}: {exc}")

    return {
        "moved": moved,
        "skipped": skipped,
        "duplicates": duplicates,
        "failed": failed,
        "merged_obsolete_won": merged_obsolete_won,
        "merged_official_won": merged_official_won,
        "duplicate_records": duplicate_records,
    }


def confirm_apply(plans_summary, args):
    if args.yes:
        return True
    if sys.stdin.isatty():
        total_opps = sum(item["opps"] for item in plans_summary)
        print(f"\nVas a mover {total_opps} oportunidad(es) entre pipelines en {len(plans_summary)} caso(s).")
        response = input("Escribe CONFIRMO para continuar: ").strip()
        if response != "CONFIRMO":
            raise SystemExit("Cancelado por el usuario.")
        return True
    raise SystemExit("--apply requiere --yes en entornos no TTY (dashboard). Abortando por seguridad.")


def parse_args():
    parser = argparse.ArgumentParser(
        description="Detecta pipelines duplicados por sucursal y migra oportunidades del mas antiguo al mas reciente.",
    )
    parser.add_argument("--location", help="Procesa solo esta sucursal (no aplica a marca).")
    parser.add_argument("--all", action="store_true", help="Procesa todas las sucursales del CSV.")
    parser.add_argument("--live", action="store_true", help="Consulta GHL en vivo en lugar de SQLite local (recomendado para precision).")
    parser.add_argument("--apply", action="store_true", help="Aplica los cambios en GHL. Sin este flag solo hace dry-run.")
    parser.add_argument("--yes", action="store_true", help="Salta la confirmacion interactiva (necesario en dashboard).")
    parser.add_argument("--max-opps", type=int, help="Limite de oportunidades a mover por plan (debug).")
    parser.add_argument("--run-id", help="ID de auditoria asignado por el dashboard.")
    parser.add_argument("--from-pipeline", help="ID del pipeline origen (obsoleto). Requiere --to-pipeline y --location. Salta la deteccion por nombre.")
    parser.add_argument("--to-pipeline", help="ID del pipeline destino (oficial). Requiere --from-pipeline y --location.")
    return parser.parse_args()


def main():
    args = parse_args()

    explicit_pair = bool(args.from_pipeline or args.to_pipeline)
    if explicit_pair:
        if not (args.from_pipeline and args.to_pipeline):
            raise SystemExit("--from-pipeline y --to-pipeline deben usarse juntos.")
        if not args.location:
            raise SystemExit("--from-pipeline/--to-pipeline requieren --location <id> (no compatible con --all).")
        if args.all:
            raise SystemExit("--from-pipeline/--to-pipeline no son compatibles con --all.")

    if not args.location and not args.all:
        raise SystemExit("Especifica --location <id> o --all para procesar todas las sucursales.")

    if args.apply and args.location == BRAND_LOCATION_ID:
        raise SystemExit("Por seguridad este script no aplica cambios sobre la cuenta principal.")

    accounts = select_accounts(location_id=args.location, all_locations=args.all, include_main=False)
    accounts = [account for account in accounts if account["location_id"] != BRAND_LOCATION_ID]
    if not accounts:
        raise SystemExit("No hay sucursales para procesar (la cuenta principal queda excluida).")

    print("=== DEDUPE PIPELINES EN SUCURSALES MP ===")
    print(f"Modo: {'APPLY (escribira en GHL)' if args.apply else 'DRY-RUN (no escribira nada)'}")
    print(f"Fuente: {'live API GHL' if args.live else 'SQLite local'}")
    if explicit_pair:
        print(f"Modo explicito: pipeline {args.from_pipeline} -> {args.to_pipeline}")
    print(f"Sucursales evaluadas: {len(accounts)}")
    print("-" * 78)

    client = sync_engine.ghl_client if args.live or args.apply else None
    if args.apply and not args.live:
        print("Nota: --apply siempre consulta GHL para mover oportunidades. La deteccion sigue usando SQLite salvo que pases --live.")

    all_plans = []
    for index, account in enumerate(accounts, 1):
        print(f"[{index}/{len(accounts)}] {account['nombre']} ({account['location_id']})")
        try:
            pipelines = load_pipelines(account, args.live, client)
        except Exception as exc:
            print(f"  ERROR consultando pipelines: {exc}")
            continue
        if explicit_pair:
            plans = build_plan_explicit(account, pipelines, args.from_pipeline, args.to_pipeline, args.live, client)
        else:
            plans = build_plan(account, pipelines, args.live, client)
        if not plans:
            print("  Sin duplicados.")
            continue
        for plan in plans:
            print_plan(account, plan)
            all_plans.append({"account": account, "plan": plan})

    if not all_plans:
        print("\nNo se detectaron pipelines duplicados. Nada que hacer.")
        return

    print("\n" + "=" * 78)
    summary = [{
        "location": entry["account"]["location_id"],
        "nombre": entry["account"]["nombre"],
        "obsolete": entry["plan"]["obsolete"]["id"],
        "official": entry["plan"]["official"]["id"],
        "opps": len(entry["plan"]["opportunities"]),
        "mapping_kind": entry["plan"]["mapping_kind"],
    } for entry in all_plans]
    print(f"Planes detectados: {len(summary)}")
    print(f"Oportunidades totales en pipelines obsoletos: {sum(item['opps'] for item in summary)}")
    unmappable = [item for item in summary if item["mapping_kind"] == "imposible"]
    if unmappable:
        print(f"Planes sin mapeo posible (se ignoran en --apply): {len(unmappable)}")

    if not args.apply:
        print("\nDRY-RUN: no se modifico nada. Usa --apply --yes para mover oportunidades.")
        return

    confirm_apply(summary, args)

    if args.run_id:
        script_audit.create_run(
            args.run_id,
            SCRIPT_NAME,
            arguments=" ".join(sys.argv[1:]),
            locations=[account["location_id"] for account in accounts],
            execution_mode="sequential",
        )

    total_moved = total_skipped = total_duplicates = total_failed = 0
    all_duplicates = []
    for entry in all_plans:
        account = entry["account"]
        plan = entry["plan"]
        if plan["mapping"] is None:
            print(f"\nSKIP {account['nombre']}: mapeo imposible.")
            continue
        print(f"\n>>> Aplicando en {account['nombre']} ({account['location_id']}) <<<")
        res = apply_plan(account, plan, args.run_id, args.max_opps, 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
        all_duplicates.extend(dup_records)
        print(f"  Movidas: {moved} | Saltadas: {skipped} | Merged (obsoleto gana): {merged_o} | Merged (oficial gana): {merged_f} | DUP no resueltos: {duplicates} | Fallidas: {failed}")

    print("\n" + "=" * 78)
    print(f"RESUMEN FINAL | Movidas: {total_moved} | Saltadas: {total_skipped} | Duplicadas en pipeline oficial: {total_duplicates} | Fallidas: {total_failed}")
    if all_duplicates:
        print("\nOportunidades NO movidas porque el contacto ya tiene una en el pipeline oficial:")
        print("(Revisar manualmente: decidir si fusionar datos o eliminar la del pipeline obsoleto)")
        for record in all_duplicates:
            print(f"  - {record['location_name']} ({record['location_id']}) | opp {record['opp_id']} {record['opp_name']} | pipeline obsoleto {record['obsolete_pipeline_id']}")
    print("\nRecomendacion: corre Sincronizar Todo para refrescar SQLite tras la migracion.")
    print("Nota: el rollback automatico del dashboard solo cubre custom fields. Para revertir estos movimientos hay que mover las oportunidades de regreso manualmente.")

    if args.run_id:
        status = "failed" if total_failed else "success"
        script_audit.update_run_status(args.run_id, status)

    if total_failed:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
