"""
Cloud Poller - Recibe cambios pendientes del Operator Panel y los aplica localmente.
Corre como background task dentro de FastAPI.

Flujo:
  1. GET pending_changes del Operator cloud (cada CLOUD_POLL_INTERVAL seg)
  2. Aplica cada cambio en SQLite (update/deactivate paquetes, combo_assign/unassign)
  3. POST ack_changes para marcar como aplicados en el cloud
"""
import asyncio
import json
import httpx
from database import SessionLocal
from models import Package, TerminalPackage, ComboRegistry
from config import (
    OPERATOR_API_URL, CLOUD_SYNC_KEY, CLOUD_POLL_INTERVAL, VENUE_ID,
)

# Backoff exponencial
_current_interval = CLOUD_POLL_INTERVAL
_max_interval = 300  # 5 min maximo
_consecutive_errors = 0


def _log(msg):
    """Print con flush para que aparezca inmediatamente en Windows."""
    print(f"[Cloud Poller] {msg}", flush=True)


async def cloud_poller():
    """Background task: pide pending_changes al Operator y aplica localmente."""
    global _current_interval, _consecutive_errors

    await asyncio.sleep(15)  # esperar a que el server arranque
    _log(f"Iniciado. URL: {OPERATOR_API_URL}")
    _log(f"Intervalo: {CLOUD_POLL_INTERVAL}seg, Venue: {VENUE_ID}")

    # Bootstrap: si las tablas de combos estan vacias, pedir todo al cloud
    try:
        await _combo_bootstrap_if_needed()
    except Exception as e:
        _log(f"Error en bootstrap de combos: {e}")

    while True:
        try:
            changes = await _fetch_pending_changes()
            if changes:
                applied_ids, failed_ids = _apply_changes(changes)
                if applied_ids:
                    await _ack_changes(applied_ids)
                    _log(f"{len(applied_ids)} cambio(s) aplicados")
                if failed_ids:
                    _log(f"{len(failed_ids)} cambio(s) fallidos (se reintentaran)")
                # Exito: resetear backoff
                _current_interval = CLOUD_POLL_INTERVAL
                _consecutive_errors = 0
            else:
                # Sin cambios pendientes, resetear backoff tambien
                _current_interval = CLOUD_POLL_INTERVAL
                _consecutive_errors = 0
        except Exception as e:
            _consecutive_errors += 1
            _current_interval = min(
                CLOUD_POLL_INTERVAL * (2 ** _consecutive_errors),
                _max_interval,
            )
            _log(f"Error ({_consecutive_errors}x): {e}. Reintentando en {_current_interval}seg")

        await asyncio.sleep(_current_interval)


async def _fetch_pending_changes():
    """GET pending_changes del Operator API."""
    url = f"{OPERATOR_API_URL}?action=pending_changes&venue_id={VENUE_ID}&key={CLOUD_SYNC_KEY}"
    async with httpx.AsyncClient(timeout=45.0) as client:
        resp = await client.get(url)

    if resp.status_code != 200:
        raise Exception(f"HTTP {resp.status_code}: {resp.text[:200]}")

    data = resp.json()
    if data.get("status") != "ok":
        raise Exception(f"Respuesta inesperada: {data}")

    return data.get("changes", [])


def _apply_changes(changes):
    """Aplica cambios en la DB local. Retorna (applied_ids, failed_ids)."""
    applied_ids = []
    failed_ids = []
    db = SessionLocal()
    try:
        for change in changes:
            change_id = change.get("id")
            action = change.get("action", "")
            package_id = change.get("package_id", "")
            payload_raw = change.get("payload")

            # Parsear payload (puede ser string JSON o dict)
            if isinstance(payload_raw, str):
                try:
                    payload = json.loads(payload_raw)
                except (json.JSONDecodeError, TypeError):
                    _log(f"Payload invalido en cambio {change_id}, marcando como fallido")
                    failed_ids.append(change_id)
                    continue
            elif isinstance(payload_raw, dict):
                payload = payload_raw
            else:
                _log(f"Payload vacio en cambio {change_id}, marcando como fallido")
                failed_ids.append(change_id)
                continue

            try:
                if action == "update":
                    _apply_package_update(db, package_id, payload)
                elif action == "deactivate":
                    _apply_package_deactivate(db, package_id)
                elif action == "combo_assign":
                    _apply_combo_assign(db, payload)
                elif action == "combo_unassign":
                    _apply_combo_unassign(db, payload)
                elif action == "combo_delete":
                    _apply_combo_delete(db, payload)
                else:
                    _log(f"Accion desconocida: {action} (id={change_id})")

                applied_ids.append(change_id)
            except Exception as e:
                _log(f"Error aplicando cambio {change_id} ({action}): {e}")
                failed_ids.append(change_id)

        db.commit()
    except Exception as e:
        db.rollback()
        raise e
    finally:
        db.close()

    return applied_ids, failed_ids


def _apply_package_update(db, package_id, payload):
    """Upsert de paquete: actualiza si existe, crea si no."""
    pkg = db.query(Package).filter(Package.id == package_id).first()
    if pkg:
        if "name" in payload:
            pkg.name = payload["name"]
        if "minutes" in payload:
            pkg.minutes = int(payload["minutes"])
        if "price" in payload:
            pkg.price = float(payload["price"])
        if "color" in payload:
            pkg.color = payload["color"]
        if "active" in payload:
            pkg.active = bool(int(payload["active"]))
        _log(f"Paquete actualizado: {package_id}")
    else:
        # Crear paquete nuevo
        name = payload.get("name", package_id)
        minutes = int(payload.get("minutes", 0))
        price = float(payload.get("price", 0))
        color = payload.get("color", "all")
        active = bool(int(payload.get("active", 1)))
        if minutes > 0 and price > 0:
            db.add(Package(
                id=package_id, name=name, minutes=minutes,
                price=price, color=color, active=active,
            ))
            _log(f"Paquete creado: {package_id}")
        else:
            _log(f"Paquete {package_id} ignorado (datos incompletos)")


def _apply_package_deactivate(db, package_id):
    """Desactiva un paquete."""
    pkg = db.query(Package).filter(Package.id == package_id).first()
    if pkg:
        pkg.active = False
        _log(f"Paquete desactivado: {package_id}")
    else:
        _log(f"Paquete {package_id} no encontrado para desactivar")


def _apply_combo_assign(db, payload):
    """Asigna un combo a una terminal."""
    agent_id = payload.get("agent_id", "")
    combo_id = payload.get("combo_id", "")
    name = payload.get("name", "")
    price_raw = payload.get("price")

    if not agent_id or not combo_id:
        raise ValueError(f"combo_assign: faltan datos (agent={agent_id}, combo={combo_id})")

    existing = db.query(TerminalPackage).filter(
        TerminalPackage.agent_id == agent_id,
        TerminalPackage.combo_id == combo_id,
    ).first()

    if existing:
        # Solo actualizar name/price si vienen en el payload (toggle no los envia)
        if name:
            existing.name = name
        if price_raw is not None:
            existing.price = float(price_raw)
        existing.active = True
        _log(f"Combo actualizado: {combo_id} -> {agent_id}")
    else:
        # Nuevo: name y price son obligatorios
        if not name or price_raw is None:
            raise ValueError(
                f"combo_assign nuevo sin name/price (agent={agent_id}, combo={combo_id})"
            )
        db.add(TerminalPackage(
            agent_id=agent_id, combo_id=combo_id,
            name=name, price=float(price_raw), active=True,
        ))
        _log(f"Combo asignado: {combo_id} -> {agent_id}")


def _apply_combo_unassign(db, payload):
    """Desasigna un combo de una terminal."""
    agent_id = payload.get("agent_id", "")
    combo_id = payload.get("combo_id", "")

    if not agent_id or not combo_id:
        raise ValueError(f"combo_unassign: faltan datos (agent={agent_id}, combo={combo_id})")

    existing = db.query(TerminalPackage).filter(
        TerminalPackage.agent_id == agent_id,
        TerminalPackage.combo_id == combo_id,
    ).first()

    if existing:
        existing.active = False
        _log(f"Combo desasignado: {combo_id} de {agent_id}")
    else:
        _log(f"Combo {combo_id}/{agent_id} no encontrado para desasignar")


def _apply_combo_delete(db, payload):
    """Elimina un combo del registro local y desactiva sus adopciones."""
    combo_id = payload.get("combo_id", "")
    if not combo_id:
        raise ValueError("combo_delete: falta combo_id")

    # Desactivar terminal_packages
    tps = db.query(TerminalPackage).filter(
        TerminalPackage.combo_id == combo_id,
    ).all()
    for tp in tps:
        tp.active = False

    # Eliminar de combo_registry
    combo = db.query(ComboRegistry).filter(
        ComboRegistry.combo_id == combo_id,
    ).first()
    if combo:
        db.delete(combo)
        _log(f"Combo eliminado: {combo_id} (+ {len(tps)} adopcion(es) desactivadas)")
    else:
        _log(f"Combo {combo_id} no encontrado en registro local (adopciones desactivadas: {len(tps)})")


async def _ack_changes(ids):
    """POST ack_changes para confirmar que los cambios fueron aplicados."""
    url = f"{OPERATOR_API_URL}?action=ack_changes"
    async with httpx.AsyncClient(timeout=45.0) as client:
        resp = await client.post(url, json={
            "key": CLOUD_SYNC_KEY,
            "ids": ids,
        })

    if resp.status_code != 200:
        _log(f"Error en ack_changes: HTTP {resp.status_code}")
    else:
        data = resp.json()
        if data.get("status") != "ok":
            _log(f"ack_changes respuesta: {data}")


async def _combo_bootstrap_if_needed():
    """Si combo_registry y terminal_packages estan vacias, pedir todo al cloud.

    Esto resuelve el caso de instalaciones frescas donde la DB es nueva
    y los pending_changes ya fueron confirmados en una instalacion anterior.
    """
    db = SessionLocal()
    try:
        combo_count = db.query(ComboRegistry).count()
        tp_count = db.query(TerminalPackage).count()

        if combo_count > 0 or tp_count > 0:
            return  # Ya hay datos, no necesita bootstrap

        _log("Tablas de combos vacias — iniciando bootstrap desde cloud...")

        url = (
            f"{OPERATOR_API_URL}?action=combo_bootstrap"
            f"&venue_id={VENUE_ID}&key={CLOUD_SYNC_KEY}"
        )
        async with httpx.AsyncClient(timeout=45.0) as client:
            resp = await client.get(url)

        if resp.status_code != 200:
            _log(f"Bootstrap HTTP {resp.status_code}: {resp.text[:200]}")
            return

        data = resp.json()
        if data.get("status") != "ok":
            _log(f"Bootstrap respuesta inesperada: {data}")
            return

        combos = data.get("combos", [])
        packages = data.get("packages", [])

        # Insertar combo_registry
        for c in combos:
            existing = db.query(ComboRegistry).filter(
                ComboRegistry.combo_id == c["combo_id"]
            ).first()
            if not existing:
                db.add(ComboRegistry(
                    combo_id=c["combo_id"],
                    agent_id=c.get("agent_id", ""),
                    venue_id=VENUE_ID,
                    config_json=c.get("config_json"),
                ))

        # Insertar terminal_packages
        for p in packages:
            existing = db.query(TerminalPackage).filter(
                TerminalPackage.agent_id == p["agent_id"],
                TerminalPackage.combo_id == p["combo_id"],
            ).first()
            if not existing:
                db.add(TerminalPackage(
                    agent_id=p["agent_id"],
                    combo_id=p["combo_id"],
                    name=p.get("name", ""),
                    price=float(p.get("price", 0)),
                    active=True,
                ))

        db.commit()
        _log(f"Bootstrap completado: {len(combos)} combos, {len(packages)} adopciones")
    except Exception as e:
        db.rollback()
        raise e
    finally:
        db.close()
