#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Helper para mutaciones seguras de workflows n8n via API REST.

Reusable. Patrón base extraído de `n8n/_add_automap_node.py` y de la memoria
acumulada sobre la API de n8n (la API publica acepta PUT con
`{name,nodes,connections,settings}`; el campo `settings` debe filtrarse a un
whitelist; tras un PUT estructural el workflow puede quedar inactivo; el
endpoint de activación/desactivación es POST /activate, POST /deactivate).

Ejemplo:
    from scripts.n8n_workflow_lib import load_credentials, N8NClient
    client = N8NClient(*load_credentials())
    wf, backup_path = client.backup_workflow(WID, label='fase_1')
    new_node = {...}
    client.add_node(wf, new_node)
    client.insert_between(wf, 'Datos API Cuenta objetivo - MARCA',
                          new_node['name'], 'Buscar Contacto Objetivo - MARCA (phone)')
    client.put_workflow(WID, wf, dry_run=True)  # dump a n8n/dryrun_*.json
    client.put_workflow(WID, wf, dry_run=False) # aplica real
    client.verify_post(WID, expected_node_names=[new_node['name']], prev_version_id=wf['versionId'])
    client.activate(WID)
"""

import argparse
import copy
import datetime
import json
import os
import re
import sys
import uuid
import urllib.error
import urllib.request


ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
N8N_DIR = os.path.join(ROOT_DIR, "n8n")

# Whitelist confirmada en `_add_automap_node.py`. Si n8n agrega settings nuevos
# útiles, actualizar aquí (una sola fuente de verdad).
ALLOWED_SETTINGS = {
    "saveExecutionProgress",
    "saveManualExecutions",
    "saveDataErrorExecution",
    "saveDataSuccessExecution",
    "executionTimeout",
    "errorWorkflow",
    "timezone",
    "executionOrder",
}


class AlreadyExistsError(RuntimeError):
    pass


class PutFailedError(RuntimeError):
    pass


class VerificationFailedError(RuntimeError):
    pass


# ---------------------------------------------------------------------------

def load_credentials(path=None):
    """Lee credenciales n8n del archivo plano `API:` / `URL:`.
    Devuelve (api_key, base_url_sin_slash_final).
    """
    if path is None:
        path = os.path.join(N8N_DIR, "n8n credencials.txt")
    api_key = base_url = None
    with open(path, "r", encoding="utf-8") as fh:
        for line in fh:
            if line.startswith("API:"):
                api_key = line.split("API:", 1)[1].strip()
            elif line.startswith("URL:"):
                base_url = line.split("URL:", 1)[1].strip().rstrip("/")
    if not api_key or not base_url:
        raise RuntimeError(f"Credenciales incompletas en {path}")
    return api_key, base_url


# ---------------------------------------------------------------------------

class N8NClient:
    def __init__(self, api_key, base_url, timeout=60):
        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.headers = {
            "X-N8N-API-KEY": api_key,
            "Accept": "application/json",
            "Content-Type": "application/json",
        }

    # ---- HTTP base ----

    def _request(self, method, path, body=None):
        url = f"{self.base_url}{path}"
        data = json.dumps(body).encode("utf-8") if body is not None else None
        req = urllib.request.Request(url, method=method, data=data, headers=self.headers)
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
                txt = resp.read().decode("utf-8", errors="replace")
                return resp.status, (json.loads(txt) if txt else None)
        except urllib.error.HTTPError as e:
            txt = e.read().decode("utf-8", errors="replace")
            try:
                payload = json.loads(txt)
            except Exception:
                payload = txt[:500]
            raise PutFailedError(f"HTTP {e.code} {method} {path}: {payload}")

    # ---- Workflow CRUD ----

    def get_workflow(self, wid):
        _, wf = self._request("GET", f"/api/v1/workflows/{wid}")
        return wf

    def backup_workflow(self, wid, label="generic"):
        wf = self.get_workflow(wid)
        ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        safe_label = re.sub(r"[^A-Za-z0-9_-]", "_", label)
        os.makedirs(N8N_DIR, exist_ok=True)
        path = os.path.join(N8N_DIR, f"backup_{safe_label}_{wid}_{ts}.json")
        with open(path, "w", encoding="utf-8") as fh:
            json.dump(wf, fh, ensure_ascii=False, indent=2)
        return wf, path

    def filter_settings(self, wf):
        s = wf.get("settings") or {}
        return {k: v for k, v in s.items() if k in ALLOWED_SETTINGS}

    def put_workflow(self, wid, wf, dry_run=True):
        """Si dry_run=True dumpea el payload a n8n/dryrun_*.json y no llama API.
        Si dry_run=False hace PUT real y retorna el workflow actualizado."""
        payload = {
            "name": wf["name"],
            "nodes": wf["nodes"],
            "connections": wf["connections"],
            "settings": self.filter_settings(wf),
        }
        ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        if dry_run:
            os.makedirs(N8N_DIR, exist_ok=True)
            path = os.path.join(N8N_DIR, f"dryrun_{wid}_{ts}.json")
            with open(path, "w", encoding="utf-8") as fh:
                json.dump(payload, fh, ensure_ascii=False, indent=2)
            return {"dry_run": True, "path": path, "node_count": len(payload["nodes"])}
        status, body = self._request("PUT", f"/api/v1/workflows/{wid}", body=payload)
        if status != 200:
            raise PutFailedError(f"PUT status {status}: {body}")
        return body

    def activate(self, wid):
        status, body = self._request("POST", f"/api/v1/workflows/{wid}/activate")
        return body

    def deactivate(self, wid):
        status, body = self._request("POST", f"/api/v1/workflows/{wid}/deactivate")
        return body

    def restore_from_backup(self, path):
        """Lee el JSON de backup y hace PUT. Devuelve el nuevo workflow."""
        with open(path, "r", encoding="utf-8") as fh:
            wf = json.load(fh)
        wid = wf["id"]
        was_active = bool(wf.get("active"))
        # Desactivar primero si está activo (necesario para PUT estructural en algunos casos).
        try:
            self.deactivate(wid)
        except Exception:
            pass
        result = self.put_workflow(wid, wf, dry_run=False)
        if was_active:
            self.activate(wid)
        return result

    # ---- Verificación ----

    def verify_post(self, wid, expected_node_names=None, prev_version_id=None):
        wf = self.get_workflow(wid)
        errors = []
        if prev_version_id and wf.get("versionId") == prev_version_id:
            errors.append(f"versionId no cambió (sigue siendo {prev_version_id})")
        nodes_by_name = {n["name"] for n in wf.get("nodes") or []}
        for name in (expected_node_names or []):
            if name not in nodes_by_name:
                errors.append(f"nodo esperado ausente: {name!r}")
        if errors:
            raise VerificationFailedError("; ".join(errors))
        return wf

    # ---- Mutaciones in-memory (operan sobre wf dict, no llaman API) ----

    @staticmethod
    def find_node(wf, name):
        for n in wf.get("nodes") or []:
            if n["name"] == name:
                return n
        return None

    @staticmethod
    def assert_idempotent(wf, new_name):
        if N8NClient.find_node(wf, new_name) is not None:
            raise AlreadyExistsError(f"El nodo {new_name!r} ya existe en el workflow.")

    @staticmethod
    def add_node(wf, node_dict):
        """Append a `nodes`. Si no trae `id`, se genera uno UUIDv4."""
        if "id" not in node_dict:
            node_dict["id"] = str(uuid.uuid4())
        wf.setdefault("nodes", []).append(node_dict)
        return node_dict

    @staticmethod
    def get_connection_targets(wf, src, output_kind="main", output_index=0):
        """Devuelve la lista de {node, type, index} que salen de `src` por output `output_index`."""
        conns = wf.get("connections") or {}
        node = conns.get(src) or {}
        branches = node.get(output_kind) or []
        if output_index >= len(branches):
            return []
        return branches[output_index] or []

    @staticmethod
    def set_connection(wf, src, dst, output_kind="main", output_index=0, dst_input_index=0):
        """Sobrescribe el sink de src→[output_index] dejando UN solo destino dst."""
        conns = wf.setdefault("connections", {})
        node = conns.setdefault(src, {})
        branches = node.setdefault(output_kind, [])
        while len(branches) <= output_index:
            branches.append([])
        branches[output_index] = [{"node": dst, "type": output_kind, "index": dst_input_index}]

    @staticmethod
    def append_connection(wf, src, dst, output_kind="main", output_index=0, dst_input_index=0):
        """Agrega dst al sink de src sin borrar los existentes."""
        conns = wf.setdefault("connections", {})
        node = conns.setdefault(src, {})
        branches = node.setdefault(output_kind, [])
        while len(branches) <= output_index:
            branches.append([])
        branches[output_index].append({"node": dst, "type": output_kind, "index": dst_input_index})

    @staticmethod
    def insert_between(wf, src, mid, dst, output_kind="main", output_index=0):
        """Reemplaza src→dst por src→mid→dst. Preserva el resto de salidas de src."""
        N8NClient.set_connection(wf, src, mid, output_kind, output_index)
        N8NClient.set_connection(wf, mid, dst, output_kind, 0)

    @staticmethod
    def branch_if(wf, if_node_name, true_target, false_target):
        """Para nodos IF: configura output 0 (true) y output 1 (false)."""
        N8NClient.set_connection(wf, if_node_name, true_target, "main", 0)
        N8NClient.set_connection(wf, if_node_name, false_target, "main", 1)

    @staticmethod
    def rename_node(wf, old_name, new_name):
        """Renombra un nodo y actualiza TODAS las referencias $('OLD') y connections."""
        node = N8NClient.find_node(wf, old_name)
        if not node:
            raise KeyError(f"Nodo {old_name!r} no existe.")
        if N8NClient.find_node(wf, new_name) is not None:
            raise AlreadyExistsError(f"Ya existe un nodo con nombre {new_name!r}.")
        node["name"] = new_name

        # Referencias $('OLD') dentro de parameters (jsonBody, url, jsCode, value, expression).
        old_re = re.compile(r"\$\(\s*['\"]" + re.escape(old_name) + r"['\"]\s*\)")

        def walk(obj):
            if isinstance(obj, dict):
                return {k: walk(v) for k, v in obj.items()}
            if isinstance(obj, list):
                return [walk(v) for v in obj]
            if isinstance(obj, str):
                return old_re.sub(f"$('{new_name}')", obj)
            return obj

        for n in wf.get("nodes") or []:
            n["parameters"] = walk(n.get("parameters") or {})

        # connections: claves (src) y values (dst entries con .node).
        conns = wf.get("connections") or {}
        if old_name in conns:
            conns[new_name] = conns.pop(old_name)
        for src, outs in conns.items():
            for kind, branches in (outs or {}).items():
                for branch in branches or []:
                    for entry in branch or []:
                        if entry.get("node") == old_name:
                            entry["node"] = new_name
        wf["connections"] = conns
        return node


# ---------------------------------------------------------------------------
# Self-test
# ---------------------------------------------------------------------------

def self_test(wid):
    client = N8NClient(*load_credentials())
    print(f"[self-test] GET workflow {wid}...")
    wf = client.get_workflow(wid)
    print(f"  name: {wf.get('name')}  active: {wf.get('active')}  nodes: {len(wf.get('nodes') or [])}")
    print(f"  versionId: {wf.get('versionId')}")

    print(f"[self-test] backup...")
    _, path = client.backup_workflow(wid, label="self_test")
    print(f"  -> {path}")
    print(f"  size: {os.path.getsize(path)} bytes")

    # In-memory: insertar un nodo dummy y hacer dry-run, sin tocar n8n.
    wf2 = copy.deepcopy(wf)
    dummy_name = "self_test_dummy_node_DELETE_ME"
    try:
        client.assert_idempotent(wf2, dummy_name)
    except AlreadyExistsError as e:
        print(f"  WARN: {e} (saltando inserción)")
        return
    dummy = {
        "parameters": {},
        "type": "n8n-nodes-base.set",
        "typeVersion": 1,
        "position": [0, 0],
        "name": dummy_name,
        "notes": "self-test dummy. Si esto llega a n8n, alguien aplicó el dry-run sin querer.",
    }
    client.add_node(wf2, dummy)

    print(f"[self-test] dry-run PUT...")
    res = client.put_workflow(wid, wf2, dry_run=True)
    print(f"  dry_run path: {res['path']}  nodes en payload: {res['node_count']}")

    # Verificar que el dryrun JSON tiene exactamente 1 nodo más que el original.
    with open(res["path"], "r", encoding="utf-8") as fh:
        dryrun = json.load(fh)
    diff_count = len(dryrun["nodes"]) - len(wf["nodes"])
    if diff_count != 1:
        raise SystemExit(f"FAIL: dryrun tiene delta de nodos = {diff_count} (esperado 1)")
    print(f"  [OK] delta nodos = +1 (correcto)")

    # Confirmar que el dummy NO está en n8n live.
    wf_live = client.get_workflow(wid)
    has_dummy = client.find_node(wf_live, dummy_name) is not None
    if has_dummy:
        raise SystemExit("FAIL: dummy llegó a n8n live (dry-run no fue dry).")
    print("  [OK] workflow live intacto (dry-run respetado)")

    print(f"[self-test] OK")


def main():
    parser = argparse.ArgumentParser(description="n8n workflow lib helper.")
    parser.add_argument("--self-test", metavar="WID", help="Correr self-test contra un workflow id.")
    args = parser.parse_args()
    if args.self_test:
        self_test(args.self_test)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()
