"""LAN → cloud sync: outbox, push, heartbeat, background worker."""
from __future__ import annotations

import asyncio
import hashlib
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional

import httpx
from sqlalchemy.orm import Session

from app.core.config import settings
from app.models.cloud_sync import CloudSyncLedger, SyncOutboxEntry
from app.models.licensing import DeploymentNode
from app.services.cloud_sync_apply import ENTITY_MODELS, apply_sync_event

logger = logging.getLogger(__name__)

SYNC_ENTITY_MAP = {cls.__name__: name for name, cls in ENTITY_MODELS.items()}

_push_scheduled = False


def is_lan_sync_client() -> bool:
    """Local/hybrid server that pushes transactions to the cloud hub."""
    mode = (settings.deployment_mode or "cloud").lower()
    return mode in ("local", "hybrid")


def should_capture_changes() -> bool:
    if not settings.cloud_sync_enabled:
        return False
    return is_lan_sync_client()


def load_node_config() -> Dict[str, Any]:
    """Read node credentials from license-node.json or env."""
    cfg: Dict[str, Any] = {}
    path = Path(settings.cloud_sync_config_path or "deploy/license-node.json")
    if path.is_file():
        try:
            cfg = json.loads(path.read_text(encoding="utf-8"))
        except Exception as e:
            logger.warning("Could not read sync config %s: %s", path, e)
    cloud_url = (
        cfg.get("cloud_url")
        or settings.license_cloud_url
        or ""
    ).rstrip("/")
    return {
        "cloud_url": cloud_url,
        "node_id": (cfg.get("node_id") or "").strip(),
        "sync_token": (cfg.get("sync_token") or "").strip(),
        "node_name": cfg.get("node_name") or "CNPERP Local",
        "config_path": str(path),
    }


class CloudSyncService:
    def __init__(self, db: Session):
        self.db = db

    def enqueue(self, entity_type: str, entity_id: str, payload: Dict[str, Any], operation: str = "upsert") -> SyncOutboxEntry:
        existing = (
            self.db.query(SyncOutboxEntry)
            .filter(
                SyncOutboxEntry.entity_type == entity_type,
                SyncOutboxEntry.entity_id == entity_id,
                SyncOutboxEntry.status == "pending",
            )
            .first()
        )
        if existing:
            existing.payload = payload
            existing.operation = operation
            existing.updated_at = datetime.utcnow()
            self.db.flush()
            schedule_push_after_commit()
            return existing

        entry = SyncOutboxEntry(
            entity_type=entity_type,
            entity_id=str(entity_id),
            operation=operation,
            payload=payload,
            status="pending",
        )
        self.db.add(entry)
        self.db.flush()
        schedule_push_after_commit()
        return entry

    def enqueue_model(self, instance: Any, operation: str = "upsert") -> Optional[SyncOutboxEntry]:
        entity_type = SYNC_ENTITY_MAP.get(instance.__class__.__name__)
        if not entity_type:
            return None
        payload = instance.to_dict() if hasattr(instance, "to_dict") else {}
        return self.enqueue(entity_type, str(getattr(instance, "id")), payload, operation)

    def pending_count(self) -> int:
        return (
            self.db.query(SyncOutboxEntry)
            .filter(SyncOutboxEntry.status == "pending")
            .count()
        )

    def status(self) -> Dict[str, Any]:
        cfg = load_node_config()
        mode = (settings.deployment_mode or "cloud").lower()
        is_lan = is_lan_sync_client()
        pending = self.pending_count() if is_lan else 0
        failed = 0
        if is_lan:
            failed = (
                self.db.query(SyncOutboxEntry)
                .filter(SyncOutboxEntry.status == "failed")
                .count()
            )

        if is_lan and settings.cloud_sync_enabled:
            ui_mode = "lan_client"
            message = None
        elif mode == "cloud":
            ui_mode = "cloud_hub"
            message = "This server receives transaction sync from registered LAN nodes."
        else:
            ui_mode = "disabled"
            message = (
                "Enable CLOUD_SYNC_ENABLED=true and DEPLOYMENT_MODE=local on the LAN server."
                if is_lan
                else "Cloud sync push runs on local/hybrid servers only."
            )

        node_count = 0
        if mode == "cloud":
            node_count = (
                self.db.query(DeploymentNode)
                .filter(DeploymentNode.is_active == True)
                .count()
            )

        return {
            "enabled": settings.cloud_sync_enabled and is_lan,
            "ui_mode": ui_mode,
            "realtime_enabled": settings.cloud_sync_realtime,
            "cloud_url": cfg.get("cloud_url") or settings.license_cloud_url or None,
            "node_id": cfg.get("node_id") or None,
            "node_configured": bool(cfg.get("node_id") and cfg.get("sync_token")),
            "license_key_configured": bool((cfg.get("license_key") or "").strip()),
            "pending_events": pending,
            "failed_events": failed,
            "deployment_mode": settings.deployment_mode or "cloud",
            "cloud_sync_master_enabled": settings.cloud_sync_enabled,
            "registered_nodes": node_count,
            "message": message,
        }

    async def push_pending_async(self, limit: Optional[int] = None) -> Dict[str, Any]:
        cfg = load_node_config()
        if not cfg.get("cloud_url") or not cfg.get("node_id") or not cfg.get("sync_token"):
            return {"success": False, "message": "Cloud node not configured. Register via license-node.json or Connect to Cloud in Settings."}

        batch_size = limit or settings.cloud_sync_batch_size
        pending = (
            self.db.query(SyncOutboxEntry)
            .filter(SyncOutboxEntry.status.in_(("pending", "failed")))
            .order_by(SyncOutboxEntry.created_at)
            .limit(batch_size)
            .all()
        )
        if not pending:
            return {"success": True, "pushed": 0, "message": "Nothing to sync"}

        events = [
            {
                "outbox_id": e.id,
                "entity_type": e.entity_type,
                "entity_id": e.entity_id,
                "operation": e.operation,
                "payload": e.payload,
            }
            for e in pending
        ]

        url = f"{cfg['cloud_url']}/api/v1/sync/ingest"
        headers = {
            "Content-Type": "application/json",
            "X-Sync-Node-Id": cfg["node_id"],
            "X-Sync-Token": cfg["sync_token"],
        }
        try:
            async with httpx.AsyncClient(timeout=60.0) as client:
                resp = await client.post(url, json={"events": events}, headers=headers)
                resp.raise_for_status()
                result = resp.json()
        except Exception as exc:
            logger.warning("Cloud sync push failed: %s", exc)
            return {"success": False, "message": str(exc), "pushed": 0}

        now = datetime.utcnow()
        applied_ids = {a.get("outbox_id") for a in result.get("applied", [])}
        for entry in pending:
            if entry.id in applied_ids:
                entry.status = "pushed"
                entry.pushed_at = now
                entry.error_message = None
            else:
                err = next(
                    (f.get("error") for f in result.get("failed", []) if f.get("outbox_id") == entry.id),
                    "Not applied by cloud",
                )
                entry.status = "failed"
                entry.retry_count = (entry.retry_count or 0) + 1
                entry.error_message = err

        self.db.commit()
        return {
            "success": True,
            "pushed": len(applied_ids),
            "failed": len(pending) - len(applied_ids),
            "message": result.get("message", "Sync complete"),
        }

    def push_pending(self, limit: Optional[int] = None) -> Dict[str, Any]:
        return asyncio.run(self.push_pending_async(limit))

    async def heartbeat_and_sync_async(self) -> Dict[str, Any]:
        cfg = load_node_config()
        if not cfg.get("cloud_url") or not cfg.get("node_id") or not cfg.get("sync_token"):
            return {"heartbeat": False, "message": "Node not configured"}

        sync_result: Dict[str, Any] = {}
        if self.pending_count() > 0:
            sync_result = await self.push_pending_async()

        url = f"{cfg['cloud_url']}/api/v1/licensing/nodes/heartbeat"
        try:
            async with httpx.AsyncClient(timeout=30.0) as client:
                resp = await client.post(
                    url,
                    json={
                        "node_id": cfg["node_id"],
                        "sync_token": cfg["sync_token"],
                        "app_version": settings.app_name,
                    },
                )
                resp.raise_for_status()
                hb = resp.json()
        except Exception as exc:
            return {"heartbeat": False, "message": str(exc), **sync_result}

        if hb.get("sync_requested"):
            sync_result = await self.push_pending_async()

        return {"heartbeat": True, "sync_requested": hb.get("sync_requested", False), **sync_result}

    def ingest_events(
        self,
        node: DeploymentNode,
        events: List[Dict[str, Any]],
    ) -> Dict[str, Any]:
        company_id = node.company_id
        applied: List[Dict[str, Any]] = []
        failed: List[Dict[str, Any]] = []

        for ev in events:
            outbox_id = ev.get("outbox_id")
            entity_type = ev.get("entity_type")
            entity_id = ev.get("entity_id")
            operation = ev.get("operation") or "upsert"
            payload = ev.get("payload")

            dup = (
                self.db.query(CloudSyncLedger)
                .filter(
                    CloudSyncLedger.node_id == node.id,
                    CloudSyncLedger.outbox_id == outbox_id,
                )
                .first()
            )
            if dup and dup.apply_status == "applied":
                applied.append({"outbox_id": outbox_id, "entity_type": entity_type, "entity_id": entity_id})
                continue

            try:
                status, err = apply_sync_event(
                    self.db,
                    entity_type=entity_type,
                    entity_id=entity_id,
                    operation=operation,
                    payload=payload,
                    company_id=company_id,
                )
                ledger = CloudSyncLedger(
                    node_id=node.id,
                    company_id=company_id,
                    outbox_id=outbox_id,
                    entity_type=entity_type,
                    entity_id=entity_id,
                    operation=operation,
                    payload=payload,
                    apply_status=status,
                    error_message=err,
                )
                self.db.add(ledger)
                self.db.flush()
                if status == "applied":
                    applied.append({"outbox_id": outbox_id, "entity_type": entity_type, "entity_id": entity_id})
                else:
                    failed.append({"outbox_id": outbox_id, "error": err or status})
            except Exception as exc:
                self.db.rollback()
                failed.append({"outbox_id": outbox_id, "error": str(exc)})
                try:
                    self.db.add(
                        CloudSyncLedger(
                            node_id=node.id,
                            company_id=company_id,
                            outbox_id=outbox_id,
                            entity_type=entity_type or "?",
                            entity_id=entity_id or "?",
                            operation=operation,
                            payload=payload,
                            apply_status="failed",
                            error_message=str(exc),
                        )
                    )
                    self.db.flush()
                except Exception:
                    self.db.rollback()

        node.last_sync_at = datetime.utcnow()
        node.last_sync_events_count = len(applied)
        node.last_sync_status = "ok" if not failed else ("partial" if applied else "failed")
        node.last_sync_error = failed[0]["error"] if failed and not applied else None
        if failed and applied:
            node.last_sync_error = f"{len(failed)} event(s) failed"
        node.sync_requested_at = None
        self.db.flush()

        return {
            "message": f"Applied {len(applied)}, failed {len(failed)}",
            "applied": applied,
            "failed": failed,
        }

    @staticmethod
    def verify_node_token(db: Session, node_id: str, sync_token: str) -> Optional[DeploymentNode]:
        token_hash = hashlib.sha256(sync_token.encode()).hexdigest()
        return (
            db.query(DeploymentNode)
            .filter(
                DeploymentNode.id == node_id,
                DeploymentNode.sync_token_hash == token_hash,
                DeploymentNode.is_active == True,
            )
            .first()
        )


def schedule_push_after_commit() -> None:
    """Fire-and-forget push when realtime sync is on (after HTTP request commits)."""
    global _push_scheduled
    if not settings.cloud_sync_realtime or not should_capture_changes():
        return
    if _push_scheduled:
        return
    _push_scheduled = True

    async def _run():
        global _push_scheduled
        await asyncio.sleep(0.5)
        try:
            from app.core.database import SessionLocal
            db = SessionLocal()
            try:
                svc = CloudSyncService(db)
                await svc.push_pending_async()
            finally:
                db.close()
        except Exception as e:
            logger.debug("Background sync push: %s", e)
        finally:
            _push_scheduled = False

    try:
        loop = asyncio.get_running_loop()
        loop.create_task(_run())
    except RuntimeError:
        pass


async def cloud_sync_background_loop() -> None:
    """Periodic heartbeat + push for LAN servers."""
    interval = max(15, settings.cloud_sync_interval_seconds)
    while True:
        await asyncio.sleep(interval)
        if not should_capture_changes():
            continue
        try:
            from app.core.database import SessionLocal
            db = SessionLocal()
            try:
                svc = CloudSyncService(db)
                await svc.heartbeat_and_sync_async()
            finally:
                db.close()
        except Exception as e:
            logger.debug("Cloud sync background loop: %s", e)
