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

Mutador con dry-run/apply para los pasos 1, 3 y 4 del plan de tags:

  PASO 1: Unificar form -> formulario en las 49 cuentas que tienen 'form'.
          Cualquier contacto con 'form' recibe 'formulario' (si no lo tiene)
          y pierde 'form'. Luego se elimina el tag entity 'form' de la cuenta.

  PASO 3: Crear el tag 'revisar' en las cuentas donde falta (46 sucursales
          + 0001 - MP - Qro DEMO).

  PASO 4: Limpieza de tags de seed/prueba:
          - Crear 'qa-test' como tag QA estandar en las 50 cuentas.
          - Eliminar los tags basura: consequat, nisi sint commodo amet,
            test_only, test-tag-2, temp, pruebas, test-update.
          GHL hace cascade automatico: los contactos taggeados pierden el tag
          al eliminar la entidad.

Auditoria:
  - Antes de eliminar cualquier tag, se guarda un snapshot pre-destructive
    en generated/migrations/tag_cleanup_<run_id>/<location_id>.json con la
    lista de contactos que tenian ese tag (para rollback futuro).
  - Cada accion se registra en script_audit con un run_id.

Uso CLI:
  python scripts/cleanup_and_unify_tags.py --dry-run
  python scripts/cleanup_and_unify_tags.py --apply --yes
"""

import argparse
import csv
import json
import os
import sys
import time
from collections import defaultdict

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 requests

from paths import MIGRATIONS_DIR, REPORTS_DIR, ensure_dirs
import script_audit

TOKENS_CSV = os.path.join(ROOT_DIR, "Bucéfalo - Mesa de control - API Tokens - MP.csv")
BASE_URL = "https://services.leadconnectorhq.com"
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"

# Tags basura a eliminar (los 6 listados en la auditoria, salvo qa-test
# que se PRESERVA como tag QA estandar). Comparacion case-insensitive.
TRASH_TAGS = {
    "consequat",
    "nisi sint commodo amet",
    "test_only",
    "test-tag-2",
    "temp",
    "pruebas",
    "test-update",
}
QA_STANDARD_TAG = "qa-test"
LEGACY_FORM_TAG = "form"
TARGET_FORM_TAG = "formulario"
REVIEW_TAG = "revisar"

API_RETRIES = 3
API_SLEEP = 0.15  # rate limit cushion entre llamadas por location


def safe_print(*args, **kwargs):
    text = kwargs.get("sep", " ").join(str(a) for a in args)
    end = kwargs.get("end", "\n")
    try:
        sys.stdout.write(text + end)
        sys.stdout.flush()
    except UnicodeEncodeError:
        enc = sys.stdout.encoding or "utf-8"
        sys.stdout.write(text.encode(enc, errors="replace").decode(enc) + end)


def load_accounts():
    out = []
    with open(TOKENS_CSV, encoding="utf-8-sig") as f:
        for row in csv.DictReader(f):
            loc = (row.get("Location_ID") or "").strip()
            tok = (row.get("API_token") or "").strip()
            name = (row.get("Nombre") or "").strip()
            if not loc or not tok:
                continue
            out.append({
                "name": name,
                "location_id": loc,
                "token": tok,
                "is_brand": loc == BRAND_LOCATION_ID,
                "is_demo": "demo" in name.lower(),
            })
    return out


def _headers(token):
    return {
        "Authorization": f"Bearer {token}",
        "Version": "2021-07-28",
        "Accept": "application/json",
        "Content-Type": "application/json",
    }


def api_request(method, url, token, json_body=None, params=None):
    last_exc = None
    for attempt in range(API_RETRIES):
        try:
            r = requests.request(method, url, headers=_headers(token), json=json_body, params=params, timeout=30)
            if r.status_code == 429:
                wait = int(r.headers.get("Retry-After", 5))
                time.sleep(wait)
                continue
            if r.status_code >= 500:
                time.sleep(2 ** attempt)
                continue
            return r
        except requests.RequestException as e:
            last_exc = e
            time.sleep(2 ** attempt)
    if last_exc:
        raise last_exc
    raise RuntimeError(f"max retries {method} {url}")


def fetch_tags(loc, token):
    r = api_request("GET", f"{BASE_URL}/locations/{loc}/tags", token)
    if r.status_code != 200:
        raise RuntimeError(f"fetch_tags HTTP {r.status_code}: {r.text[:200]}")
    data = r.json()
    return data.get("tags", data) if isinstance(data, dict) else data


def fetch_contacts_with_tag(loc, token, tag_name):
    """GET /contacts/?locationId={loc}&query=... no filtra bien por tag exacto.
    Mejor uso POST /contacts/search con filter de tags."""
    all_contacts = []
    page = 1
    while True:
        body = {
            "locationId": loc,
            "pageLimit": 100,
            "page": page,
            "filters": [
                {"field": "tags", "operator": "contains", "value": tag_name}
            ],
        }
        r = api_request("POST", f"{BASE_URL}/contacts/search", token, json_body=body)
        if r.status_code != 200:
            # Fallback: query string en /contacts/?query=tag_name (no garantizado)
            return _fallback_fetch_contacts_with_tag(loc, token, tag_name)
        data = r.json()
        contacts = data.get("contacts", []) or []
        if not contacts:
            break
        for c in contacts:
            # Filtrar localmente — el `contains` de GHL puede ser laxo
            tags = [t.lower().strip() for t in (c.get("tags") or [])]
            if tag_name.lower() in tags:
                all_contacts.append(c)
        if len(contacts) < 100:
            break
        page += 1
        if page > 200:  # safety
            break
    return all_contacts


def _fallback_fetch_contacts_with_tag(loc, token, tag_name):
    """Fallback lento: paginar TODOS los contactos y filtrar localmente."""
    all_contacts = []
    skip = 0
    while True:
        r = api_request("GET", f"{BASE_URL}/contacts/", token, params={
            "locationId": loc, "limit": 100, "skip": skip
        })
        if r.status_code != 200:
            break
        contacts = (r.json() or {}).get("contacts", []) or []
        if not contacts:
            break
        for c in contacts:
            tags = [t.lower().strip() for t in (c.get("tags") or [])]
            if tag_name.lower() in tags:
                all_contacts.append(c)
        if len(contacts) < 100:
            break
        skip += 100
        if skip > 20000:
            break
    return all_contacts


def create_tag(loc, token, name):
    r = api_request("POST", f"{BASE_URL}/locations/{loc}/tags", token, json_body={"name": name})
    if r.status_code not in (200, 201):
        raise RuntimeError(f"create_tag {name} HTTP {r.status_code}: {r.text[:200]}")
    return r.json().get("tag", {})


def delete_tag_entity(loc, token, tag_id):
    r = api_request("DELETE", f"{BASE_URL}/locations/{loc}/tags/{tag_id}", token)
    if r.status_code != 200:
        raise RuntimeError(f"delete_tag {tag_id} HTTP {r.status_code}: {r.text[:200]}")
    return r.json()


def add_tag_to_contact(token, contact_id, tag_name):
    r = api_request("POST", f"{BASE_URL}/contacts/{contact_id}/tags", token, json_body={"tags": [tag_name]})
    if r.status_code not in (200, 201):
        raise RuntimeError(f"add_tag {tag_name} -> {contact_id} HTTP {r.status_code}: {r.text[:200]}")
    return r.json()


def remove_tag_from_contact(token, contact_id, tag_name, location_id):
    r = api_request("DELETE", f"{BASE_URL}/contacts/{contact_id}/tags/{tag_name}", token, params={"locationId": location_id})
    if r.status_code != 200:
        raise RuntimeError(f"remove_tag {tag_name} <- {contact_id} HTTP {r.status_code}: {r.text[:200]}")
    return r.json()


def normalize(s):
    return (s or "").strip().lower()


def write_snapshot(run_id, location_id, payload):
    base = os.path.join(MIGRATIONS_DIR, f"tag_cleanup_{run_id}")
    os.makedirs(base, exist_ok=True)
    path = os.path.join(base, f"{location_id}.json")
    with open(path, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
    return path


def run(dry_run, run_id, log):
    ensure_dirs()
    accounts = load_accounts()
    log(f"Cuentas: {len(accounts)}")

    summary = {
        "run_id": run_id,
        "dry_run": dry_run,
        "step1_form_to_formulario": {"accounts_targeted": 0, "contacts_remapped": 0, "form_entity_deleted": 0, "errors": 0},
        "step3_create_revisar": {"created": 0, "skipped_existing": 0, "errors": 0},
        "step4a_create_qa_test": {"created": 0, "skipped_existing": 0, "errors": 0},
        "step4b_delete_trash": {"trash_entities_deleted": 0, "trash_contacts_cascaded": 0, "errors": 0},
        "per_account": [],
    }

    for idx, acc in enumerate(accounts, 1):
        loc = acc["location_id"]
        token = acc["token"]
        flag = " [DEMO]" if acc["is_demo"] else (" [MARCA]" if acc["is_brand"] else "")
        log(f"\n[{idx}/{len(accounts)}] {acc['name']}{flag} ({loc})")

        acc_report = {"name": acc["name"], "location_id": loc, "actions": [], "errors": []}
        try:
            tags = fetch_tags(loc, token)
        except Exception as e:
            log(f"  ERROR fetch_tags: {e}")
            acc_report["errors"].append(f"fetch_tags: {e}")
            summary["per_account"].append(acc_report)
            continue

        tags_by_name = defaultdict(list)
        for t in tags:
            tags_by_name[normalize(t.get("name"))].append(t)

        # ---------- PASO 1: form -> formulario ----------
        form_entities = tags_by_name.get(LEGACY_FORM_TAG, [])
        if form_entities:
            summary["step1_form_to_formulario"]["accounts_targeted"] += 1
            try:
                affected_contacts = fetch_contacts_with_tag(loc, token, LEGACY_FORM_TAG)
                if affected_contacts:
                    log(f"  PASO 1: 'form' presente en {len(affected_contacts)} contactos. Remapeando a 'formulario'.")
                else:
                    log(f"  PASO 1: tag 'form' sin contactos asignados. Solo se elimina la entidad.")
                # Snapshot
                snap_payload = {
                    "step": "form_to_formulario",
                    "location_id": loc,
                    "tag_entities": form_entities,
                    "contacts": [
                        {"id": c.get("id"), "tags_before": c.get("tags") or []}
                        for c in affected_contacts
                    ],
                }
                snap_path = write_snapshot(run_id, loc, snap_payload) if not dry_run else "(dry-run, no snapshot)"
                acc_report["actions"].append({
                    "step": "form_to_formulario",
                    "affected_contacts": len(affected_contacts),
                    "tag_entities": [e["id"] for e in form_entities],
                    "snapshot": snap_path,
                })
                if not dry_run:
                    # 1) Asegurar que cada contacto con 'form' tenga 'formulario'.
                    #    NO removemos 'form' del contacto via endpoint por-contacto
                    #    (DELETE /contacts/{id}/tags/{name} responde 404 porque GHL
                    #    espera tag_id, no name). En su lugar confiamos en el cascade
                    #    automatico al borrar la entidad del tag (validado en tests).
                    for c in affected_contacts:
                        cid = c["id"]
                        ctags = [normalize(t) for t in (c.get("tags") or [])]
                        if TARGET_FORM_TAG not in ctags:
                            add_tag_to_contact(token, cid, TARGET_FORM_TAG)
                        summary["step1_form_to_formulario"]["contacts_remapped"] += 1
                        if run_id:
                            try:
                                cid_audit = script_audit.record_change(
                                    run_id, loc, "contact", cid, "", "remap_form_to_formulario", None,
                                    {"removed_via_cascade": LEGACY_FORM_TAG, "ensured": TARGET_FORM_TAG},
                                )
                                if cid_audit:
                                    script_audit.mark_change(cid_audit, "applied")
                            except Exception:
                                pass
                    # 2) Eliminar entidad(es) tag 'form' -> cascade quita 'form' de
                    #    todos los contactos automaticamente.
                    for ent in form_entities:
                        delete_tag_entity(loc, token, ent["id"])
                        summary["step1_form_to_formulario"]["form_entity_deleted"] += 1
                        if run_id:
                            try:
                                cid_audit = script_audit.record_change(
                                    run_id, loc, "tag", ent["id"], "", "delete_tag_entity", None,
                                    {"name": LEGACY_FORM_TAG},
                                )
                                if cid_audit:
                                    script_audit.mark_change(cid_audit, "applied")
                            except Exception:
                                pass
                else:
                    summary["step1_form_to_formulario"]["contacts_remapped"] += len(affected_contacts)
                    summary["step1_form_to_formulario"]["form_entity_deleted"] += len(form_entities)
            except Exception as e:
                log(f"  ERROR paso1: {e}")
                summary["step1_form_to_formulario"]["errors"] += 1
                acc_report["errors"].append(f"step1: {e}")

        # ---------- PASO 3: crear 'revisar' si falta ----------
        if not tags_by_name.get(REVIEW_TAG):
            # No la incluimos en MP DEMO original que ya la tiene; aqui solo
            # se ejecuta cuando falta. Aplicar a todas las cuentas que la
            # necesitan, incluida 0001 - MP - Qro DEMO.
            log(f"  PASO 3: crear tag 'revisar'.")
            try:
                if not dry_run:
                    new_tag = create_tag(loc, token, REVIEW_TAG)
                    summary["step3_create_revisar"]["created"] += 1
                    if run_id:
                        try:
                            cid_audit = script_audit.record_change(
                                run_id, loc, "tag", new_tag.get("id", ""), "", "create_tag", None,
                                {"name": REVIEW_TAG},
                            )
                            if cid_audit:
                                script_audit.mark_change(cid_audit, "applied")
                        except Exception:
                            pass
                else:
                    summary["step3_create_revisar"]["created"] += 1
                acc_report["actions"].append({"step": "create_revisar", "name": REVIEW_TAG})
            except Exception as e:
                log(f"  ERROR paso3: {e}")
                summary["step3_create_revisar"]["errors"] += 1
                acc_report["errors"].append(f"step3: {e}")
        else:
            summary["step3_create_revisar"]["skipped_existing"] += 1

        # ---------- PASO 4a: crear 'qa-test' si falta ----------
        if not tags_by_name.get(QA_STANDARD_TAG):
            log(f"  PASO 4a: crear tag QA estandar '{QA_STANDARD_TAG}'.")
            try:
                if not dry_run:
                    new_tag = create_tag(loc, token, QA_STANDARD_TAG)
                    summary["step4a_create_qa_test"]["created"] += 1
                    if run_id:
                        try:
                            cid_audit = script_audit.record_change(
                                run_id, loc, "tag", new_tag.get("id", ""), "", "create_tag", None,
                                {"name": QA_STANDARD_TAG},
                            )
                            if cid_audit:
                                script_audit.mark_change(cid_audit, "applied")
                        except Exception:
                            pass
                else:
                    summary["step4a_create_qa_test"]["created"] += 1
                acc_report["actions"].append({"step": "create_qa_test", "name": QA_STANDARD_TAG})
            except Exception as e:
                log(f"  ERROR paso4a: {e}")
                summary["step4a_create_qa_test"]["errors"] += 1
                acc_report["errors"].append(f"step4a: {e}")
        else:
            summary["step4a_create_qa_test"]["skipped_existing"] += 1

        # ---------- PASO 4b: eliminar tags basura ----------
        for tag_name in TRASH_TAGS:
            entities = tags_by_name.get(tag_name, [])
            if not entities:
                continue
            try:
                # Snapshot pre-destructive de contactos afectados
                affected = fetch_contacts_with_tag(loc, token, tag_name)
                snap_payload = {
                    "step": "delete_trash_tag",
                    "location_id": loc,
                    "tag_name": tag_name,
                    "tag_entities": entities,
                    "contacts": [
                        {"id": c.get("id"), "tags_before": c.get("tags") or []}
                        for c in affected
                    ],
                }
                snap_path = write_snapshot(run_id, loc, snap_payload) if not dry_run else "(dry-run, no snapshot)"
                log(f"  PASO 4b: borrar '{tag_name}' ({len(entities)} entidad/es, {len(affected)} contactos cascadearan)")
                acc_report["actions"].append({
                    "step": "delete_trash_tag",
                    "tag_name": tag_name,
                    "entities_deleted": [e["id"] for e in entities],
                    "contacts_cascaded": len(affected),
                    "snapshot": snap_path,
                })
                if not dry_run:
                    for ent in entities:
                        delete_tag_entity(loc, token, ent["id"])
                        summary["step4b_delete_trash"]["trash_entities_deleted"] += 1
                        if run_id:
                            try:
                                cid_audit = script_audit.record_change(
                                    run_id, loc, "tag", ent["id"], "", "delete_tag_entity", None,
                                    {"name": tag_name, "cascaded_contacts": len(affected)},
                                )
                                if cid_audit:
                                    script_audit.mark_change(cid_audit, "applied")
                            except Exception:
                                pass
                    summary["step4b_delete_trash"]["trash_contacts_cascaded"] += len(affected)
                else:
                    summary["step4b_delete_trash"]["trash_entities_deleted"] += len(entities)
                    summary["step4b_delete_trash"]["trash_contacts_cascaded"] += len(affected)
            except Exception as e:
                log(f"  ERROR paso4b {tag_name}: {e}")
                summary["step4b_delete_trash"]["errors"] += 1
                acc_report["errors"].append(f"step4b {tag_name}: {e}")

        summary["per_account"].append(acc_report)
        time.sleep(API_SLEEP)

    log("\n" + "=" * 72)
    log("RESUMEN" + (" (DRY-RUN)" if dry_run else " (APLICADO)"))
    log("=" * 72)
    s1 = summary["step1_form_to_formulario"]
    log(f"PASO 1 form->formulario:  cuentas={s1['accounts_targeted']}  contactos_remap={s1['contacts_remapped']}  entidades_form_borradas={s1['form_entity_deleted']}  errores={s1['errors']}")
    s3 = summary["step3_create_revisar"]
    log(f"PASO 3 crear revisar:     creadas={s3['created']}  ya_existia={s3['skipped_existing']}  errores={s3['errors']}")
    s4a = summary["step4a_create_qa_test"]
    log(f"PASO 4a crear qa-test:    creadas={s4a['created']}  ya_existia={s4a['skipped_existing']}  errores={s4a['errors']}")
    s4b = summary["step4b_delete_trash"]
    log(f"PASO 4b limpieza basura:  entidades_borradas={s4b['trash_entities_deleted']}  contactos_cascade={s4b['trash_contacts_cascaded']}  errores={s4b['errors']}")

    return summary


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--dry-run", dest="dry_run", action="store_true", default=True, help="Default: simular")
    parser.add_argument("--apply", dest="dry_run", action="store_false", help="Aplicar cambios reales")
    parser.add_argument("--yes", action="store_true", help="Confirmar apply")
    parser.add_argument("--json", default=None, help="Ruta de salida JSON")
    parser.add_argument("--run-id", default=None, help="ID de run para audit (auto si no se da)")
    args = parser.parse_args()

    if not args.dry_run and not args.yes:
        safe_print("ERROR: --apply requiere --yes para confirmar.")
        sys.exit(2)

    if args.run_id:
        run_id = args.run_id
    else:
        import uuid
        run_id = f"smt-{uuid.uuid4().hex[:12]}"

    if not args.dry_run:
        try:
            script_audit.init_audit_db()
            script_audit.create_run(
                run_id, "cleanup_and_unify_tags",
                arguments=("--apply" if args.yes else "--dry-run"),
                locations=[BRAND_LOCATION_ID],  # se aplica multi-location pero registramos Brand como ancla
                execution_mode="sequential",
            )
        except Exception as e:
            safe_print(f"WARN: no se pudo crear run de audit: {e}")
            run_id = None

    result = run(dry_run=args.dry_run, run_id=run_id, log=safe_print)

    if not args.dry_run and run_id:
        try:
            status = "completed" if (
                result["step1_form_to_formulario"]["errors"] == 0 and
                result["step3_create_revisar"]["errors"] == 0 and
                result["step4a_create_qa_test"]["errors"] == 0 and
                result["step4b_delete_trash"]["errors"] == 0
            ) else "completed_with_errors"
            script_audit.update_run_status(run_id, status)
        except Exception:
            pass

    if args.json:
        os.makedirs(os.path.dirname(args.json) or ".", exist_ok=True)
        with open(args.json, "w", encoding="utf-8") as f:
            json.dump(result, f, ensure_ascii=False, indent=2)
        safe_print(f"\nReporte JSON: {args.json}")


if __name__ == "__main__":
    main()
