"""
ERGON -- Dispatcher de acciones del parser de bitacora.

Dos funciones publicas:

1. trigger_parse(conn, codigo, entry_id, hoy_override=None)
   Llama al parser, guarda el borrador en daily_log. No toca tablas de negocio.
   Retorna el borrador para que el frontend lo muestre al usuario.

2. confirmar_acciones(conn, codigo, entry_id, acciones_aprobadas, usuario)
   Aplica las acciones (posiblemente editadas por el usuario) en transaccion.
   Upsert en: asistencia_diaria, avance_mensual, presupuesto_mensual,
              contratos, materiales, subcontratistas.
   Actualiza daily_log con acciones_derivadas y estado 'confirmed'.

Principios:
- Upsert idempotente por (obra_id, claves naturales) para no duplicar.
- Resolucion nombre->id de rubros y subcontratistas. Falla fuerte si no matchean.
- Transaction completa: o todas las acciones pegan, o ninguna.
- Todas las filas creadas/actualizadas se devuelven en el diff, para que
  el frontend muestre exactamente que cambio.
"""
from __future__ import annotations

import json
import re
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional


_FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_MES_RE = re.compile(r"^\d{4}-\d{2}(-\d{2})?$")


def _validar_fecha(valor: Any, campo: str = "fecha") -> None:
    """Formato esperado YYYY-MM-DD. Tira ValueError si no matchea."""
    if not isinstance(valor, str) or not _FECHA_RE.match(valor):
        raise ValueError(f"{campo}: formato invalido, esperado YYYY-MM-DD (got {valor!r})")


def _validar_mes(valor: Any, campo: str = "mes") -> None:
    """Formato esperado YYYY-MM o YYYY-MM-DD. Tira ValueError si no matchea."""
    if not isinstance(valor, str) or not _MES_RE.match(valor):
        raise ValueError(f"{campo}: formato invalido, esperado YYYY-MM o YYYY-MM-DD (got {valor!r})")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _obra_id_por_codigo(conn: sqlite3.Connection, codigo: str) -> Optional[int]:
    row = conn.execute("SELECT id FROM obras WHERE codigo = ?", (codigo,)).fetchone()
    return row["id"] if row else None


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _rubro_id_por_nombre(conn: sqlite3.Connection, obra_id: int, nombre: str) -> Optional[int]:
    row = conn.execute(
        "SELECT id FROM rubros_obra WHERE obra_id = ? AND nombre = ?",
        (obra_id, nombre)
    ).fetchone()
    return row["id"] if row else None


def _subcontratista_id_por_nombre(conn: sqlite3.Connection, obra_id: int, nombre: str) -> Optional[int]:
    row = conn.execute(
        "SELECT id FROM subcontratistas WHERE obra_id = ? AND nombre = ?",
        (obra_id, nombre)
    ).fetchone()
    return row["id"] if row else None


# ---------------------------------------------------------------------------
# Tool handlers: 1 por tool del parser
# Cada handler recibe (conn, obra_id, input_dict) y retorna dict con
# {"tabla": str, "id": int, "op": "insert"|"update", "dato": {...}}
# Tiran ValueError si hay datos invalidos o referencias que no matchean.
# ---------------------------------------------------------------------------

def _apply_asistencia(conn: sqlite3.Connection, obra_id: int, inp: dict) -> dict:
    fecha = inp.get("fecha")
    total = inp.get("total_personal")
    if not fecha or total is None:
        raise ValueError("upsert_asistencia: faltan fecha o total_personal")
    _validar_fecha(fecha)
    lluvia = inp.get("lluvia_mm") or 0.0
    dia_perdido = 1 if inp.get("dia_perdido") else 0
    notas = inp.get("notas")
    row = conn.execute(
        "SELECT id, total_personal, lluvia_mm, dia_perdido, notas "
        "FROM asistencia_diaria WHERE obra_id = ? AND fecha = ?",
        (obra_id, fecha)
    ).fetchone()
    if row:
        prev_values = {
            "total_personal": row["total_personal"],
            "lluvia_mm": row["lluvia_mm"],
            "dia_perdido": row["dia_perdido"],
            "notas": row["notas"],
        }
        conn.execute(
            "UPDATE asistencia_diaria SET total_personal=?, lluvia_mm=?, "
            "dia_perdido=?, notas=? WHERE id=?",
            (int(total), float(lluvia), dia_perdido, notas, row["id"])
        )
        return {"tabla": "asistencia_diaria", "id": row["id"], "op": "update",
                "dato": {"fecha": fecha, "total_personal": int(total)},
                "prev_values": prev_values}
    cur = conn.execute(
        "INSERT INTO asistencia_diaria (obra_id, fecha, total_personal, lluvia_mm, "
        "dia_perdido, notas) VALUES (?,?,?,?,?,?)",
        (obra_id, fecha, int(total), float(lluvia), dia_perdido, notas)
    )
    return {"tabla": "asistencia_diaria", "id": cur.lastrowid, "op": "insert",
            "dato": {"fecha": fecha, "total_personal": int(total)}}


def _apply_avance_mensual(conn: sqlite3.Connection, obra_id: int, inp: dict) -> dict:
    rubro_nombre = inp.get("rubro_nombre")
    mes = inp.get("mes")
    real = inp.get("real_acum_pct")
    if not rubro_nombre or not mes or real is None:
        raise ValueError("upsert_avance_mensual: faltan rubro_nombre, mes o real_acum_pct")
    _validar_mes(mes)
    rubro_id = _rubro_id_por_nombre(conn, obra_id, rubro_nombre)
    if not rubro_id:
        raise ValueError(f"rubro '{rubro_nombre}' no existe en la obra")
    plan = inp.get("plan_acum_pct")
    mes_iso = mes if len(mes) == 10 else f"{mes}-01"  # normaliza YYYY-MM -> YYYY-MM-01
    row = conn.execute(
        "SELECT id, plan_acum_pct, real_acum_pct FROM avance_mensual "
        "WHERE obra_id=? AND rubro_id=? AND mes=?",
        (obra_id, rubro_id, mes_iso)
    ).fetchone()
    if row:
        prev_values = {
            "plan_acum_pct": row["plan_acum_pct"],
            "real_acum_pct": row["real_acum_pct"],
        }
        conn.execute(
            "UPDATE avance_mensual SET plan_acum_pct=?, real_acum_pct=? WHERE id=?",
            (plan, float(real), row["id"])
        )
        return {"tabla": "avance_mensual", "id": row["id"], "op": "update",
                "dato": {"rubro": rubro_nombre, "mes": mes_iso, "real": float(real)},
                "prev_values": prev_values}
    cur = conn.execute(
        "INSERT INTO avance_mensual (obra_id, rubro_id, mes, plan_acum_pct, real_acum_pct) "
        "VALUES (?,?,?,?,?)",
        (obra_id, rubro_id, mes_iso, plan, float(real))
    )
    return {"tabla": "avance_mensual", "id": cur.lastrowid, "op": "insert",
            "dato": {"rubro": rubro_nombre, "mes": mes_iso, "real": float(real)}}


def _apply_presupuesto_mensual(conn: sqlite3.Connection, obra_id: int, inp: dict) -> dict:
    rubro_nombre = inp.get("rubro_nombre")
    mes = inp.get("mes")
    concepto = inp.get("concepto")
    monto = inp.get("monto")
    if not rubro_nombre or not mes or concepto not in ("plan", "real") or monto is None:
        raise ValueError("upsert_presupuesto_mensual: faltan campos requeridos")
    _validar_mes(mes)
    rubro_id = _rubro_id_por_nombre(conn, obra_id, rubro_nombre)
    if not rubro_id:
        raise ValueError(f"rubro '{rubro_nombre}' no existe en la obra")
    mes_iso = mes if len(mes) == 10 else f"{mes}-01"
    col_a_update = "plan" if concepto == "plan" else "real"
    row = conn.execute(
        "SELECT id, plan, real FROM presupuesto_mensual "
        "WHERE obra_id=? AND rubro_id=? AND mes=?",
        (obra_id, rubro_id, mes_iso)
    ).fetchone()
    if row:
        prev_values = {"plan": row["plan"], "real": row["real"]}
        conn.execute(
            f"UPDATE presupuesto_mensual SET {col_a_update}=? WHERE id=?",
            (float(monto), row["id"])
        )
        return {"tabla": "presupuesto_mensual", "id": row["id"], "op": "update",
                "dato": {"rubro": rubro_nombre, "mes": mes_iso,
                         "concepto": concepto, "monto": float(monto)},
                "prev_values": prev_values}
    # Insert: el otro concepto queda null
    plan_val = float(monto) if concepto == "plan" else None
    real_val = float(monto) if concepto == "real" else None
    cur = conn.execute(
        "INSERT INTO presupuesto_mensual (obra_id, rubro_id, mes, plan, real) "
        "VALUES (?,?,?,?,?)",
        (obra_id, rubro_id, mes_iso, plan_val, real_val)
    )
    return {"tabla": "presupuesto_mensual", "id": cur.lastrowid, "op": "insert",
            "dato": {"rubro": rubro_nombre, "mes": mes_iso,
                     "concepto": concepto, "monto": float(monto)}}


def _apply_contrato(conn: sqlite3.Connection, obra_id: int, inp: dict) -> dict:
    tipo = inp.get("tipo")
    descripcion = inp.get("descripcion")
    fecha_emision = inp.get("fecha_emision")
    estado = inp.get("estado")
    if not tipo or not descripcion or not fecha_emision or not estado:
        raise ValueError("upsert_contrato: faltan tipo/descripcion/fecha_emision/estado")
    contratista_nombre = inp.get("subcontratista_nombre")
    contratista_id = None
    if contratista_nombre:
        contratista_id = _subcontratista_id_por_nombre(conn, obra_id, contratista_nombre)
        # Nota: no creamos subcontratista acá -- si no existe, el parser debió
        # llamar a upsert_subcontratista antes. Si no, contratista_id queda NULL.
    fecha_firma = inp.get("fecha_firma") or None
    fecha_vence = inp.get("fecha_vence") or None
    monto = inp.get("monto")

    # Upsert por (obra_id, descripcion, fecha_emision) como clave natural
    row = conn.execute(
        "SELECT id, tipo, contratista_id, fecha_firma, fecha_vence, estado, monto "
        "FROM contratos WHERE obra_id=? AND descripcion=? AND fecha_emision=?",
        (obra_id, descripcion, fecha_emision)
    ).fetchone()
    if row:
        prev_values = {
            "tipo": row["tipo"],
            "contratista_id": row["contratista_id"],
            "fecha_firma": row["fecha_firma"],
            "fecha_vence": row["fecha_vence"],
            "estado": row["estado"],
            "monto": row["monto"],
        }
        conn.execute(
            "UPDATE contratos SET tipo=?, contratista_id=?, fecha_firma=?, "
            "fecha_vence=?, estado=?, monto=? WHERE id=?",
            (tipo, contratista_id, fecha_firma, fecha_vence, estado,
             float(monto) if monto is not None else None, row["id"])
        )
        return {"tabla": "contratos", "id": row["id"], "op": "update",
                "dato": {"descripcion": descripcion, "estado": estado},
                "prev_values": prev_values}
    cur = conn.execute(
        "INSERT INTO contratos (obra_id, contratista_id, tipo, descripcion, "
        "fecha_emision, fecha_firma, fecha_vence, estado, monto) "
        "VALUES (?,?,?,?,?,?,?,?,?)",
        (obra_id, contratista_id, tipo, descripcion, fecha_emision,
         fecha_firma, fecha_vence, estado,
         float(monto) if monto is not None else None)
    )
    return {"tabla": "contratos", "id": cur.lastrowid, "op": "insert",
            "dato": {"descripcion": descripcion, "estado": estado}}


def _apply_material(conn: sqlite3.Connection, obra_id: int, inp: dict) -> dict:
    item = inp.get("item")
    unidad = inp.get("unidad")
    if not item or not unidad:
        raise ValueError("upsert_material: faltan item o unidad")
    recibido_delta = float(inp.get("recibido_delta") or 0)
    consumido_delta = float(inp.get("consumido_delta") or 0)
    row = conn.execute(
        "SELECT id, unidad, recibido, consumido, ultima_update FROM materiales "
        "WHERE obra_id=? AND item=?",
        (obra_id, item)
    ).fetchone()
    if row:
        prev_values = {
            "unidad": row["unidad"],
            "recibido": row["recibido"],
            "consumido": row["consumido"],
            "ultima_update": row["ultima_update"],
        }
        conn.execute(
            "UPDATE materiales SET unidad=?, recibido=?, consumido=?, "
            "ultima_update=? WHERE id=?",
            (unidad, row["recibido"] + recibido_delta,
             row["consumido"] + consumido_delta, _now_iso(), row["id"])
        )
        return {"tabla": "materiales", "id": row["id"], "op": "update",
                "dato": {"item": item, "delta_recibido": recibido_delta,
                         "delta_consumido": consumido_delta},
                "prev_values": prev_values}
    cur = conn.execute(
        "INSERT INTO materiales (obra_id, item, unidad, recibido, consumido) "
        "VALUES (?,?,?,?,?)",
        (obra_id, item, unidad, recibido_delta, consumido_delta)
    )
    return {"tabla": "materiales", "id": cur.lastrowid, "op": "insert",
            "dato": {"item": item, "delta_recibido": recibido_delta,
                     "delta_consumido": consumido_delta}}


def _apply_subcontratista(conn: sqlite3.Connection, obra_id: int, inp: dict) -> dict:
    nombre = inp.get("nombre")
    if not nombre:
        raise ValueError("upsert_subcontratista: falta nombre")
    # Si ya existe, es no-op (el parser no debería llamarla, pero defensivo)
    existing = _subcontratista_id_por_nombre(conn, obra_id, nombre)
    if existing:
        return {"tabla": "subcontratistas", "id": existing, "op": "noop",
                "dato": {"nombre": nombre, "ya_existia": True}}
    rubro_nombre = inp.get("rubro_nombre")
    rubro_id = _rubro_id_por_nombre(conn, obra_id, rubro_nombre) if rubro_nombre else None
    contacto = inp.get("contacto")
    # Calcular siguiente orden
    next_orden_row = conn.execute(
        "SELECT COALESCE(MAX(orden), 0) + 1 AS next FROM subcontratistas WHERE obra_id=?",
        (obra_id,)
    ).fetchone()
    orden = next_orden_row["next"]
    cur = conn.execute(
        "INSERT INTO subcontratistas (obra_id, orden, nombre, rubro_id, contacto) "
        "VALUES (?,?,?,?,?)",
        (obra_id, orden, nombre, rubro_id, contacto)
    )
    return {"tabla": "subcontratistas", "id": cur.lastrowid, "op": "insert",
            "dato": {"nombre": nombre, "rubro": rubro_nombre}}


TOOL_HANDLERS = {
    "upsert_asistencia":          _apply_asistencia,
    "upsert_avance_mensual":      _apply_avance_mensual,
    "upsert_presupuesto_mensual": _apply_presupuesto_mensual,
    "upsert_contrato":            _apply_contrato,
    "upsert_material":            _apply_material,
    "upsert_subcontratista":      _apply_subcontratista,
}


# ---------------------------------------------------------------------------
# API publica
# ---------------------------------------------------------------------------

def trigger_parse(
    conn: sqlite3.Connection,
    codigo: str,
    entry_id: int,
    hoy_override: Optional[str] = None,
) -> dict:
    """Corre el parser y guarda el borrador en daily_log.parse_borrador.
    Retorna {ok, borrador, status} en shape consistente con otras funciones del API."""
    # Guard: no re-parsear entries ya confirmados (evita downgrade 'confirmed'->'proposed'
    # y ahorra una llamada costosa a Claude). El frontend ya lo previene en el boton,
    # pero un POST directo al endpoint deberia ser rechazado por el backend.
    state_row = conn.execute(
        "SELECT parse_estado FROM daily_log WHERE id=?", (entry_id,)
    ).fetchone()
    if state_row and state_row["parse_estado"] == "confirmed":
        return {"ok": False,
                "error": "entry ya confirmado; usar /rollback antes de re-parsear",
                "status": 409}

    # Import tardio para evitar cargar anthropic SDK al importar api_obras
    from parser_bitacora import parse_bitacora

    result = parse_bitacora(conn, codigo, entry_id, hoy_override=hoy_override)
    if not result.get("ok"):
        # Guardar estado de error en daily_log si el entry existia
        try:
            conn.execute(
                "UPDATE daily_log SET parse_estado=?, parse_procesado_at=? WHERE id=?",
                ("error", _now_iso(), entry_id)
            )
            conn.commit()
        except sqlite3.Error:
            pass
        return {"ok": False, "error": result.get("error", "parse failed"), "status": 500}

    borrador_json = json.dumps({
        "acciones": result["acciones"],
        "meta": result["meta"],
    }, ensure_ascii=False)
    conn.execute(
        "UPDATE daily_log SET parse_estado=?, parse_borrador=?, parse_procesado_at=? "
        "WHERE id=?",
        ("proposed", borrador_json, _now_iso(), entry_id)
    )
    conn.commit()

    return {
        "ok": True,
        "borrador": {
            "entry_id": entry_id,
            "acciones": result["acciones"],
            "meta": result["meta"],
        },
        "status": 200,
    }


def confirmar_acciones(
    conn: sqlite3.Connection,
    codigo: str,
    entry_id: int,
    acciones_aprobadas: list[dict],
    usuario: str,
) -> dict:
    """Aplica las acciones aprobadas en una transaccion unica.
    Si alguna falla, rollback de todo y retorna errores.
    acciones_aprobadas: lista de {"tool": str, "input": dict}."""

    obra_id = _obra_id_por_codigo(conn, codigo)
    if obra_id is None:
        return {"ok": False, "errors": [f"obra {codigo} no encontrada"], "status": 404}

    entry_row = conn.execute(
        "SELECT id, obra_id, parse_estado FROM daily_log WHERE id=?", (entry_id,)
    ).fetchone()
    if not entry_row:
        return {"ok": False, "errors": [f"entry {entry_id} no encontrado"], "status": 404}
    if entry_row["obra_id"] != obra_id:
        return {"ok": False, "errors": ["entry no pertenece a la obra"], "status": 400}
    if entry_row["parse_estado"] == "confirmed":
        return {"ok": False, "errors": ["entry ya fue confirmado anteriormente"], "status": 409}

    if not isinstance(acciones_aprobadas, list) or not acciones_aprobadas:
        return {"ok": False, "errors": ["acciones_aprobadas vacia o invalida"], "status": 400}

    # Ordenar: subcontratistas primero para que otras tools puedan referenciarlos
    def _peso(accion):
        return 0 if accion.get("tool") == "upsert_subcontratista" else 1
    ordenadas = sorted(acciones_aprobadas, key=_peso)

    diff = []
    errors = []
    try:
        conn.execute("BEGIN")
        for accion in ordenadas:
            tool = accion.get("tool")
            inp = accion.get("input", {}) or {}
            handler = TOOL_HANDLERS.get(tool)
            if not handler:
                errors.append(f"tool '{tool}' desconocida")
                continue
            try:
                ref = handler(conn, obra_id, inp)
                diff.append({"tool": tool, "resultado": ref})
            except ValueError as ve:
                errors.append(f"{tool}: {ve}")
            except sqlite3.Error as se:
                errors.append(f"{tool}: DB error: {se}")

        if errors:
            conn.execute("ROLLBACK")
            return {"ok": False, "errors": errors, "status": 400}

        # Actualizar daily_log con diff y estado
        conn.execute(
            "UPDATE daily_log SET parse_estado=?, acciones_derivadas=?, "
            "parse_confirmado_at=?, parse_confirmado_por=? WHERE id=?",
            ("confirmed", json.dumps(diff, ensure_ascii=False),
             _now_iso(), usuario, entry_id)
        )
        conn.commit()
    except Exception as e:
        try:
            conn.execute("ROLLBACK")
        except sqlite3.Error:
            pass
        return {"ok": False, "errors": [f"unexpected: {e}"], "status": 500}

    return {"ok": True, "diff": diff, "count": len(diff), "status": 200}


def get_borrador(conn: sqlite3.Connection, codigo: str, entry_id: int) -> dict:
    """Devuelve el borrador almacenado en daily_log.parse_borrador sin re-llamar al parser.
    Usado cuando el usuario abre el modal tras un reload de la pagina."""
    obra_id = _obra_id_por_codigo(conn, codigo)
    if obra_id is None:
        return {"ok": False, "error": f"obra {codigo} no encontrada", "status": 404}
    row = conn.execute(
        "SELECT id, obra_id, parse_estado, parse_borrador, parse_procesado_at, "
        "parse_confirmado_at FROM daily_log WHERE id=?",
        (entry_id,)
    ).fetchone()
    if not row:
        return {"ok": False, "error": "entry no encontrado", "status": 404}
    if row["obra_id"] != obra_id:
        return {"ok": False, "error": "entry no pertenece a la obra", "status": 400}
    if not row["parse_borrador"]:
        return {"ok": False, "error": "sin borrador almacenado", "status": 404}
    try:
        borrador = json.loads(row["parse_borrador"])
    except (ValueError, TypeError):
        return {"ok": False, "error": "borrador corrupto", "status": 500}
    return {
        "ok": True,
        "borrador": {
            "entry_id": entry_id,
            "acciones": borrador.get("acciones", []),
            "meta": borrador.get("meta", {}),
            "parse_estado": row["parse_estado"],
            "parse_procesado_at": row["parse_procesado_at"],
            "parse_confirmado_at": row["parse_confirmado_at"],
        },
        "status": 200,
    }


# Tablas donde los inserts se revierten por id directo.
# Los updates se revierten si el diff guarda `prev_values` (entries nuevos).
# Entries 'confirmed' anteriores al patch 2026-04-24 no tienen snapshot: skip.
_ROLLBACKABLE_TABLES = {
    "asistencia_diaria", "avance_mensual", "presupuesto_mensual",
    "contratos", "materiales", "subcontratistas",
}

# Whitelist de columnas restaurables por tabla. Protege contra JSON
# manipulado -- el UPDATE de rollback solo pisa columnas que esten aca.
_ROLLBACKABLE_COLS = {
    "asistencia_diaria": {"total_personal", "lluvia_mm", "dia_perdido", "notas"},
    "avance_mensual":    {"plan_acum_pct", "real_acum_pct"},
    "presupuesto_mensual": {"plan", "real"},
    "contratos":         {"tipo", "contratista_id", "fecha_firma",
                          "fecha_vence", "estado", "monto"},
    "materiales":        {"unidad", "recibido", "consumido", "ultima_update"},
}


def rollback_acciones(conn: sqlite3.Connection, codigo: str, entry_id: int) -> dict:
    """Revierte los INSERTs aplicados al confirmar un borrador.
    Lee `daily_log.acciones_derivadas` y borra filas con op='insert' por id.
    Los op='update' no se pueden deshacer sin snapshot (se cuentan como skipped).
    Marca daily_log.parse_estado='skipped' y limpia acciones_derivadas.

    Ventana de uso: 5 min post-confirm (el frontend controla el timing).
    El backend no valida tiempo -- confia en quien llama."""
    obra_id = _obra_id_por_codigo(conn, codigo)
    if obra_id is None:
        return {"ok": False, "error": f"obra {codigo} no encontrada", "status": 404}

    row = conn.execute(
        "SELECT id, obra_id, parse_estado, acciones_derivadas FROM daily_log WHERE id=?",
        (entry_id,)
    ).fetchone()
    if not row:
        return {"ok": False, "error": "entry no encontrado", "status": 404}
    if row["obra_id"] != obra_id:
        return {"ok": False, "error": "entry no pertenece a la obra", "status": 400}
    if row["parse_estado"] != "confirmed":
        return {"ok": False, "error": f"no hay confirm para revertir (estado={row['parse_estado']})",
                "status": 409}
    if not row["acciones_derivadas"]:
        return {"ok": False, "error": "sin acciones_derivadas guardadas", "status": 400}

    try:
        diff = json.loads(row["acciones_derivadas"])
    except (ValueError, TypeError):
        return {"ok": False, "error": "acciones_derivadas corruptas", "status": 500}

    reverted: list[dict] = []
    skipped: list[dict] = []
    errors: list[str] = []
    try:
        conn.execute("BEGIN")
        for item in diff:
            ref = item.get("resultado") or {}
            tabla = ref.get("tabla")
            fila_id = ref.get("id")
            op = ref.get("op")
            if tabla not in _ROLLBACKABLE_TABLES or not fila_id:
                skipped.append({"tabla": tabla, "id": fila_id, "motivo": "tabla no soportada"})
                continue
            if op == "insert":
                try:
                    # Nombres de tabla vienen de una whitelist fija, seguros para f-string.
                    conn.execute(f"DELETE FROM {tabla} WHERE id=?", (fila_id,))
                    reverted.append({"tabla": tabla, "id": fila_id, "op": "insert"})
                except sqlite3.Error as se:
                    errors.append(f"{tabla}#{fila_id}: {se}")
            elif op == "update":
                prev_values = ref.get("prev_values")
                allowed_cols = _ROLLBACKABLE_COLS.get(tabla, set())
                # Filtrar solo las columnas de prev_values que estan en la whitelist.
                to_restore = {k: v for k, v in (prev_values or {}).items()
                              if k in allowed_cols}
                if not to_restore:
                    # Entry 'confirmed' anterior al patch (sin prev_values) o
                    # prev_values con solo columnas no whitelisted.
                    skipped.append({"tabla": tabla, "id": fila_id,
                                    "motivo": "update sin snapshot"})
                    continue
                try:
                    # Nombres de columnas vienen de whitelist fija, seguros para f-string.
                    set_clause = ", ".join(f"{col}=?" for col in to_restore)
                    params = list(to_restore.values()) + [fila_id]
                    conn.execute(
                        f"UPDATE {tabla} SET {set_clause} WHERE id=?", params
                    )
                    reverted.append({"tabla": tabla, "id": fila_id, "op": "update",
                                     "restored": sorted(to_restore.keys())})
                except sqlite3.Error as se:
                    errors.append(f"{tabla}#{fila_id}: {se}")
            else:
                # noop (ej. subcontratista ya_existia): nada que deshacer.
                skipped.append({"tabla": tabla, "id": fila_id,
                                "motivo": f"op={op} sin accion"})

        if errors:
            conn.execute("ROLLBACK")
            return {"ok": False, "errors": errors, "status": 500}

        conn.execute(
            "UPDATE daily_log SET parse_estado=?, acciones_derivadas=?, "
            "parse_confirmado_at=NULL, parse_confirmado_por=NULL WHERE id=?",
            ("skipped", None, entry_id)
        )
        conn.commit()
    except Exception as e:
        try:
            conn.execute("ROLLBACK")
        except sqlite3.Error:
            pass
        return {"ok": False, "errors": [f"unexpected: {e}"], "status": 500}

    return {
        "ok": True,
        "reverted": reverted,
        "skipped": skipped,
        "count_reverted": len(reverted),
        "count_skipped": len(skipped),
        "status": 200,
    }
