#!/usr/bin/env python3
"""
╔══════════════════════════════════════════════════════════════╗
║   CGHEVEN Secure Auth Module  —  cgheven_auth.py             ║
║   Version: 1.0.0                                             ║
║                                                              ║
║   IMPORTANT: Yeh file CGHEVEN_Asset_Library.py ke           ║
║   SAATH WALE FOLDER mein rakho.                              ║
║                                                              ║
║   Windows:                                                   ║
║   C:/ProgramData/Blackmagic Design/DaVinci Resolve/          ║
║   Fusion/Scripts/Utility/                                    ║
╚══════════════════════════════════════════════════════════════╝
"""

import os
import sys
import json
import hashlib
import platform
import time
import socket
import base64
import secrets
import threading
import http.server
import urllib.request
import urllib.error
import urllib.parse
import ssl
import builtins

# Verbose debug (safe: masks secrets)
DEBUG_AUTH = bool(int(os.getenv("CGHEVEN_AUTH_DEBUG", "0") or "0"))


def _safe_print(*args, **kwargs):
    """
    DaVinci Resolve embedded Python: background threads pe ``print()`` kabhi
    ``SystemError: <built-in function write> returned a result with an exception set``
    throw karta hai — stdout hook thread-safe nahi hota. Activation/heartbeat
    threads ko kabhi is wajah se fail nahi hona chahiye.
    """
    try:
        builtins.print(*args, **kwargs)
    except BaseException:
        pass


def _mask_key(k: str) -> str:
    k = str(k or "")
    if len(k) <= 8:
        return "***"
    return k[:6] + "…" + k[-4:]

def set_debug(enabled: bool = True):
    global DEBUG_AUTH
    DEBUG_AUTH = bool(enabled)

# ══════════════════════════════════════════════════════════════
#  CONFIG — Yahan apna Strapi server URL daalo
# ══════════════════════════════════════════════════════════════

# Env-overridable for local testing (CGHEVEN_API_BASE) — defaults to production.
LICENSE_SERVER    = (os.environ.get("CGHEVEN_API_BASE") or "https://api.cgheven.com").strip().rstrip("/")
# LICENSE_SERVER    = "http://localhost:1337"
# Website base for the BlenderKit-style browser login (addon-login page lives here).
WEB_BASE          = (os.environ.get("CGHEVEN_WEB_BASE") or "https://cgheven.com").strip().rstrip("/")
# Loopback ports tried in order for the login handoff callback.
LOGTO_LOOPBACK_PORTS = [62490, 62491, 62492]
ADDON_TYPE        = "davinci"                     # davinci | blender | bundle
ADDON_SLUG        = "davinci-asset-library"       # server can optionally use slug routing
SESSION_FILE      = os.path.join(
    os.path.expanduser("~"), ".cgheven_session_v3.json"
)
OFFLINE_GRACE_HRS = 72    # Internet nahi toh 72 ghante kaam karega 
 
# ══════════════════════════════════════════════════════════════
#  MACHINE FINGERPRINT   test
#  Har machine ka unique hash — key share hone se rokta hai
# ══════════════════════════════════════════════════════════════

def get_machine_fingerprint() -> str:
    """
    SHA256(hostname + username + OS) — same machine pe hamesha same result.
    Koi admin rights nahi chahiye.
    """
    try:
        hostname = socket.gethostname()
    except Exception:
        hostname = "unknown-host"

    username = (
        os.getenv("USERNAME")
        or os.getenv("USER")
        or os.getenv("LOGNAME")
        or "unknown-user"
    )

    os_name = platform.system()
    raw     = f"{hostname}|{username}|{os_name}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def get_machine_label() -> str:
    """Admin dashboard mein dikhne wala friendly naam."""
    try:
        return f"{socket.gethostname()} ({platform.system()})"
    except Exception:
        return f"Unknown Device ({platform.system()})"


# ══════════════════════════════════════════════════════════════
#  HTTP HELPERS
# ══════════════════════════════════════════════════════════════

def _http_post(path: str, payload: dict,
               token: str = None, timeout: int = 12,
               max_attempts: int = 3) -> dict:
    """POST request to license server."""
    url  = f"{LICENSE_SERVER}{path}"
    body = json.dumps(payload).encode("utf-8")
    headers = {
        "Content-Type": "application/json",
        "User-Agent":   "CGHEVEN-Addon/3.0",
        "Accept":       "application/json",
    }
    if token:
        headers["Authorization"] = f"Bearer {token}"

    req = urllib.request.Request(url, data=body, headers=headers, method="POST")

    # Basic retry for flaky networks / TLS handshake / temporary 5xx
    last_err = None
    attempts = max(1, int(max_attempts or 3))
    for attempt in range(attempts):
        try:
            if DEBUG_AUTH:
                safe = dict(payload or {})
                if "key" in safe:
                    safe["key"] = _mask_key(str(safe.get("key", "")))
                if "session_token" in safe:
                    safe["session_token"] = "***"
                _safe_print(f"[AUTH DEBUG] POST {url} attempt={attempt+1} timeout={timeout}s payload={safe}")
            with urllib.request.urlopen(req, timeout=timeout) as r:
                raw = r.read().decode("utf-8")
                if not raw:
                    raise Exception("Empty response from server")
                if DEBUG_AUTH:
                    _safe_print(f"[AUTH DEBUG] HTTP {getattr(r,'status','?')} body={raw[:300]}")
                return json.loads(raw)
        except urllib.error.HTTPError as e:
            # Retry on temporary server errors
            if DEBUG_AUTH:
                try:
                    b = e.read().decode("utf-8", errors="replace")
                except Exception:
                    b = ""
                _safe_print(f"[AUTH DEBUG] HTTPError code={e.code} reason={e.reason} body={b[:300]}")
            if int(getattr(e, "code", 0) or 0) >= 500 and attempt < attempts - 1:
                time.sleep(0.4 * (attempt + 1))
                last_err = e
                continue
            raise
        except (urllib.error.URLError, socket.timeout, TimeoutError, ssl.SSLError) as e:
            last_err = e
            if DEBUG_AUTH:
                _safe_print(f"[AUTH DEBUG] Net/TLS error: {type(e).__name__}: {e}")
            if attempt < attempts - 1:
                time.sleep(0.5 * (attempt + 1))
                continue
            raise
        except Exception as e:
            last_err = e
            if DEBUG_AUTH:
                _safe_print(f"[AUTH DEBUG] Unknown error: {type(e).__name__}: {e}")
            if attempt < attempts - 1:
                time.sleep(0.4 * (attempt + 1))
                continue
            raise
    if last_err:
        raise last_err


def _http_get(path: str, params: dict = None,
              token: str = None, timeout: int = 15) -> dict:
    """GET request to license server (proxy calls ke liye)."""
    url = f"{LICENSE_SERVER}{path}"
    if params:
        url += "?" + urllib.parse.urlencode(
            params, quote_via=urllib.parse.quote, safe="[]$"
        )
    headers = {
        "User-Agent": "CGHEVEN-Addon/3.0",
        "Accept":     "application/json",
    }
    if token:
        headers["Authorization"] = f"Bearer {token}"

    req = urllib.request.Request(url, headers=headers)
    last_err = None
    for attempt in range(3):
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                raw = r.read().decode("utf-8")
                if not raw:
                    raise Exception("Empty response from server")
                return json.loads(raw)
        except urllib.error.HTTPError as e:
            if int(getattr(e, "code", 0) or 0) >= 500 and attempt < 2:
                time.sleep(0.4 * (attempt + 1))
                last_err = e
                continue
            raise
        except (urllib.error.URLError, socket.timeout, TimeoutError, ssl.SSLError) as e:
            last_err = e
            if attempt < 2:
                time.sleep(0.5 * (attempt + 1))
                continue
            raise
        except Exception as e:
            last_err = e
            if attempt < 2:
                time.sleep(0.4 * (attempt + 1))
                continue
            raise
    if last_err:
        raise last_err
    raise Exception("Request failed")


# ══════════════════════════════════════════════════════════════
#  SESSION — LOCAL SAVE / LOAD
#  Machine fingerprint se encrypt karta hai —
#  doosri machine pe session file kaam nahi karti
# ══════════════════════════════════════════════════════════════

def _save_session(data: dict):
    """Session ko locally encrypt karke save karo."""
    try:
        raw  = json.dumps(data).encode("utf-8")
        fp   = get_machine_fingerprint()
        # Simple XOR with fingerprint — machine-locked
        mask = (fp * ((len(raw) // len(fp)) + 1))[:len(raw)]
        enc  = bytes(b ^ ord(m) for b, m in zip(raw, mask))
        encoded = base64.b64encode(enc).decode("utf-8")
        with open(SESSION_FILE, "w", encoding="utf-8") as f:
            f.write(encoded)
    except Exception as e:
        _safe_print(f"[AUTH] Session save failed: {e}")


def _load_session() -> dict | None:
    """Locally saved session load karo."""
    try:
        with open(SESSION_FILE, "r", encoding="utf-8") as f:
            encoded = f.read().strip()
        if not encoded:
            return None
        enc  = base64.b64decode(encoded)
        fp   = get_machine_fingerprint()
        mask = (fp * ((len(enc) // len(fp)) + 1))[:len(enc)]
        raw  = bytes(b ^ ord(m) for b, m in zip(enc, mask))
        return json.loads(raw.decode("utf-8"))
    except Exception:
        return None


def _clear_session():
    """Session file delete karo — logout / revoke pe."""
    try:
        if os.path.exists(SESSION_FILE):
            os.remove(SESSION_FILE)
    except Exception:
        pass


# ══════════════════════════════════════════════════════════════
#  MAIN AUTH FUNCTIONS
# ══════════════════════════════════════════════════════════════

def _extract_activate_fields(result: dict) -> tuple[str, str, str]:
    """
    Activate API kabhi flat JSON deti hai, kabhi Strapi-style nested ``data`` /
    ``attributes``, ya ``jwt`` / ``token`` alias. Returns:
    (session_token, plan, expires_at)
    """
    if not isinstance(result, dict):
        return "", "", ""

    token_keys  = ("session_token", "jwt", "access_token", "token")
    plan_keys   = ("plan", "tier", "plan_name")
    expiry_keys = ("expires_at", "expiresAt", "expiry", "expires")

    def _tok(d: dict) -> str:
        for k in token_keys:
            v = d.get(k)
            if v is not None and str(v).strip():
                return str(v).strip()
        return ""

    def _plan(d: dict) -> str:
        for k in plan_keys:
            v = d.get(k)
            if v is not None and str(v).strip():
                return str(v).strip()
        return ""

    def _exp(d: dict) -> str:
        for k in expiry_keys:
            v = d.get(k)
            if v is not None and str(v).strip():
                return str(v).strip()
        return ""

    layers = [result]
    data = result.get("data")
    if isinstance(data, dict):
        layers.append(data)
        attrs = data.get("attributes")
        if isinstance(attrs, dict):
            layers.append(attrs)

    for layer in layers:
        t = _tok(layer)
        if t:
            p = _plan(layer) or _plan(result)
            x = _exp(layer) or _exp(result)
            return t, p, x

    return "", "", ""


def _extract_plan_fields(result: dict) -> dict:
    """Carry plan / plan_label / is_premium / expires_at out of an activate/heartbeat
    response (flat OR Strapi nested ``data``/``attributes``). Mirrors the Blender addon's
    ``_flatten_license_payload`` so a plan change picked up by the heartbeat (Free → Pro/
    Studio, or an expiry) can refresh the addon without a re-login.

    Returns dict with keys: session_token, plan, plan_label, expires_at, is_premium.
    ``is_premium`` is None when the server did not send it (so we never overwrite a
    cached value with a guess).
    """
    out = {"session_token": "", "plan": "", "plan_label": "",
           "expires_at": "", "is_premium": None}
    if not isinstance(result, dict):
        return out

    layers = [result]
    data = result.get("data")
    if isinstance(data, dict):
        attrs = data.get("attributes")
        layers.append(attrs if isinstance(attrs, dict) else data)

    tok, plan, exp = _extract_activate_fields(result)
    out["session_token"] = tok
    out["plan"]          = plan
    out["expires_at"]    = exp

    for layer in layers:
        if not isinstance(layer, dict):
            continue
        if not out["plan_label"]:
            pl = layer.get("plan_label")
            if pl is not None and str(pl).strip():
                out["plan_label"] = str(pl).strip()
        if out["is_premium"] is None and "is_premium" in layer:
            try:
                out["is_premium"] = bool(layer.get("is_premium"))
            except Exception:
                pass
    return out


def _apply_plan_fields(sess: dict, flat: dict):
    """Merge plan / plan_label / is_premium / expires_at from a heartbeat response into the
    cached session dict (in place), so a server-side plan change reflects in the addon UI
    without the user logging out and back in. Mirrors the Blender addon's ``_apply_plan_fields``."""
    if not isinstance(sess, dict) or not isinstance(flat, dict):
        return
    if flat.get("plan"):
        sess["plan"] = flat["plan"]
    if flat.get("plan_label"):
        sess["plan_label"] = flat["plan_label"]
    if flat.get("expires_at"):
        sess["expires_at"] = flat["expires_at"]
    if flat.get("is_premium") is not None:
        sess["is_premium"] = flat["is_premium"]


def activate_license(key: str) -> dict:
    """
    Strapi se key verify karo.

    Args:
        key: License key (CGHV-XXXXXXXX-XXXXXXXX-XXXXXXXX)

    Returns:
        dict with: session_token, plan, expires_at

    Raises:
        LicenseError on any failure
    """
    machine_hash  = get_machine_fingerprint()
    machine_label = get_machine_label()

    payload = {
        "key":           key.strip().upper(),
        "machine_hash":  machine_hash,
        "machine_label": machine_label,
        "addon_type":    ADDON_TYPE,
        "addon_slug":    ADDON_SLUG,
    }

    try:
        # Kam retries taake Activate UI "Validating" pe minute tak na atke (har try = timeout tak wait)
        result = _http_post(
            "/api/addon-license/activate",
            payload,
            timeout=18,
            max_attempts=2,
        )
    except urllib.error.HTTPError as e:
        body = {}
        try:
            body = json.loads(e.read().decode("utf-8"))
        except Exception:
            pass
        # Strapi error format: {"error": {"message": "..."}}
        detail = (
            body.get("error", {}).get("message")
            or body.get("message")
            or e.reason
        )
        raise LicenseError(detail, int(getattr(e, "code", 0) or 0))
    except urllib.error.URLError as e:
        reason = getattr(e, "reason", "")
        raise LicenseError(f"Network error while contacting license server: {reason}", 0)
    except ssl.SSLError as e:
        raise LicenseError(f"SSL/TLS error while contacting license server: {e}", 0)
    except socket.timeout:
        raise LicenseError("Request timed out while contacting license server.", 0)
    except Exception as e:
        raise LicenseError(f"Activation error: {str(e)}", 0)

    if not isinstance(result, dict):
        raise LicenseError("Server ne valid JSON response nahi diya.", 0)

    sess_token, plan, expires_at = _extract_activate_fields(result)
    if not sess_token:
        if DEBUG_AUTH:
            try:
                preview = json.dumps(result, ensure_ascii=False)[:500]
                _safe_print(f"[AUTH DEBUG] Activate response (no token found): {preview}")
            except Exception:
                pass
        raise LicenseError(
            "Server ne valid session token nahi diya (empty ya nested JSON). "
            "CGHEVEN_AUTH_DEBUG=1 se full response dekho.",
            0,
        )

    # Sirf valid token milne pe session save karo (empty token file skip popup na banaye)
    session = {
        "session_token":       sess_token,
        "plan":                plan,
        "expires_at":          expires_at,
        "machine_hash":        machine_hash,
        "offline_grace_until": time.time() + OFFLINE_GRACE_HRS * 3600,
        "saved_at":            time.time(),
    }
    _save_session(session)
    _safe_print(f"[AUTH] License activated — plan: {session['plan']}")
    # Main app ``_session_result`` ko flat dict chahiye (hamesha ``session_token`` top-level)
    return {
        "session_token": sess_token,
        "plan":          plan,
        "expires_at":    expires_at,
    }


def login_with_account(status_cb=None, register: bool = False) -> dict:
    """BlenderKit-style website-broker login.

    Opens the system browser to our website's /addon-login page (the site logs the
    user in + shows an Authorize page), catches the short handoff ``code`` on a
    loopback port, and redeems it at the backend for the SAME session payload as
    ``activate_license()``. Saves the session via ``_save_session`` and returns a
    flat dict ``{session_token, plan, expires_at}``. Runs the caller's thread; the
    caller should invoke this in a background thread. Raises on failure.

    Args:
        status_cb: optional callable(str) for progress messages.
        register:  True opens the sign-up screen first.
    """
    def _st(msg):
        if status_cb:
            try:
                status_cb(msg)
            except Exception:
                pass

    machine_hash  = get_machine_fingerprint()
    machine_label = get_machine_label()

    # CSRF/replay guard for the loopback hop.
    state = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode()

    result = {"code": None, "state": None, "error": None}
    done = threading.Event()

    class _Handler(http.server.BaseHTTPRequestHandler):
        def log_message(self, *a):
            pass

        def do_GET(self):
            parsed = urllib.parse.urlparse(self.path)
            if parsed.path.rstrip("/") != "/consumer/exchange":
                self.send_response(404)
                self.end_headers()
                return
            q = urllib.parse.parse_qs(parsed.query)
            result["code"]  = (q.get("code")  or [None])[0]
            result["state"] = (q.get("state") or [None])[0]
            result["error"] = (q.get("error") or [None])[0]
            # The handoff code is a backend-signed credential, so success only needs
            # a code + no error; state is a soft check below.
            ok = bool(result["code"]) and not result["error"]
            # Land the user on OUR website's branded success page (not a bare loopback page).
            dest = WEB_BASE + "/addon-login/done?" + urllib.parse.urlencode({
                "addon":  ADDON_TYPE,
                "status": "ok" if ok else (result["error"] or "failed"),
            })
            try:
                self.send_response(302)
                self.send_header("Location", dest)
                self.end_headers()
            except Exception:
                pass
            done.set()

    # 1) Bind the first free loopback port. ThreadingHTTPServer: each connection
    #    (incl. the browser's idle preconnect socket after the 302) gets its own
    #    thread, so shutdown never blocks.
    httpd = None
    chosen_redirect = None
    for _port in LOGTO_LOOPBACK_PORTS:
        for host in ("localhost", "127.0.0.1"):
            try:
                httpd = http.server.ThreadingHTTPServer((host, _port), _Handler)
                httpd.daemon_threads = True
                chosen_redirect = f"http://localhost:{_port}/consumer/exchange/"
                break
            except OSError:
                httpd = None
                continue
        if httpd is not None:
            break
    if httpd is None:
        raise LicenseError(
            "All login ports busy — close other CGHeven login windows and retry", 0
        )

    try:
        threading.Thread(target=httpd.serve_forever, daemon=True).start()
        # 2) Open the browser to OUR website's addon-login page.
        _params = {
            "redirect": chosen_redirect,
            "state":    state,
            "addon":    ADDON_TYPE,
            "machine":  machine_hash,
            "label":    machine_label,
        }
        if register:
            _params["screen"] = "register"
        login_url = WEB_BASE + "/addon-login?" + urllib.parse.urlencode(_params)
        _st("Waiting for website login…")
        try:
            import webbrowser
            webbrowser.open(login_url)
        except Exception:
            pass
        # 3) Wait for the loopback callback (3 min).
        if not done.wait(timeout=180):
            raise LicenseError("Login timed out", 0)
        if result["error"]:
            raise LicenseError(
                "Login cancelled" if result["error"] == "cancelled"
                else f"Login error: {result['error']}", 0
            )
        if not result["code"]:
            raise LicenseError("Login failed (no code returned)", 0)
        if result["state"] and result["state"] != state:
            # Soft CSRF check: a stale browser tab / round-trip can change the echoed
            # state. The handoff code is backend-signed, so we log and proceed.
            _safe_print(
                f"[AUTH] login state differs (got={result['state']!r} "
                f"want={state!r}) — proceeding via signed code"
            )
    finally:
        # Shut down in the background — never let a lingering browser socket delay login.
        try:
            threading.Thread(target=httpd.shutdown, daemon=True).start()
        except Exception:
            pass

    # 4) Redeem the handoff code → same session payload as key activation.
    _st("Finalizing…")
    try:
        res = _http_post(
            "/api/logto/addon-exchange",
            {"code": result["code"]},
            timeout=20,
            max_attempts=2,
        )
    except urllib.error.HTTPError as e:
        body = {}
        try:
            body = json.loads(e.read().decode("utf-8"))
        except Exception:
            pass
        detail = (
            body.get("error", {}).get("message")
            or body.get("message")
            or getattr(e, "reason", "")
        )
        raise LicenseError(detail or "Login exchange failed", int(getattr(e, "code", 0) or 0))
    except (urllib.error.URLError, socket.timeout, TimeoutError, ssl.SSLError) as e:
        raise LicenseError(f"Network error while finalizing login: {e}", 0)
    except Exception as e:
        raise LicenseError(f"Login exchange error: {str(e)}", 0)

    if not isinstance(res, dict):
        raise LicenseError("Server ne valid JSON response nahi diya.", 0)

    sess_token, plan, expires_at = _extract_activate_fields(res)
    if not sess_token:
        raise LicenseError("Login succeeded but server ne session token nahi diya.", 0)

    # Save the session in the SAME shape key-activation saves, plus display fields.
    session = {
        "session_token":       sess_token,
        "plan":                plan,
        "plan_label":          res.get("plan_label") or plan,
        "is_premium":          bool(res.get("is_premium")),
        "expires_at":          expires_at,
        "login_method":        "logto",
        "email":               res.get("email", "") or "",
        "name":                res.get("name", "") or "",
        "subscription_status": res.get("subscription_status", "") or "",
        "machine_hash":        machine_hash,
        "offline_grace_until": time.time() + OFFLINE_GRACE_HRS * 3600,
        "saved_at":            time.time(),
    }
    _save_session(session)
    _safe_print(f"[AUTH] Account login successful — plan: {plan}")
    return {
        "session_token": sess_token,
        "plan":          plan,
        "expires_at":    expires_at,
    }


def load_cached_session() -> dict | None:
    """
    Cached session load karo (pichle activation se).

    Returns:
        session dict agar valid session hai, None otherwise

    Raises:
        LicenseError agar server ne reject kiya (expired/revoked)
    """
    sess = _load_session()
    if not sess:
        return None

    # Machine check — doosri machine ki session file kaam nahi karegi
    if sess.get("machine_hash") != get_machine_fingerprint():
        _safe_print("[AUTH] Machine fingerprint mismatch — clearing session")
        _clear_session()
        return None

    token = str(sess.get("session_token", "") or "").strip()
    if not token:
        _safe_print("[AUTH] Empty session token in file — clearing session")
        _clear_session()
        return None

    # Online check — heartbeat se verify karo
    try:
        result = _http_post(
            "/api/addon-license/heartbeat",
            {"machine_hash": sess["machine_hash"]},
            token=token,
            timeout=8,
        )
        # Fresh token + plan fields mile toh save karo (flat ya nested response).
        # plan_label / is_premium bhi update karo taake Free → Pro change re-login ke
        # bagair reflect ho.
        flat = _extract_plan_fields(result)
        if flat["session_token"]:
            sess["session_token"] = flat["session_token"]
        _apply_plan_fields(sess, flat)
        sess["offline_grace_until"] = time.time() + OFFLINE_GRACE_HRS * 3600
        _save_session(sess)
        _safe_print("[AUTH] Session refreshed from server")
        return sess

    except urllib.error.HTTPError as e:
        body = {}
        try:
            body = json.loads(e.read().decode("utf-8"))
        except Exception:
            pass
        detail = (
            body.get("error", {}).get("message")
            or body.get("message")
            or e.reason
        )
        # 401/402/403 = server ne reject kiya — session invalid
        if e.code in (401, 402, 403):
            _clear_session()
            raise LicenseError(detail, e.code)
        # Other HTTP errors — treat as offline
        _safe_print(f"[AUTH] Server HTTP {e.code} — trying offline grace")

    except urllib.error.URLError:
        _safe_print("[AUTH] No internet — checking offline grace period")
    except Exception as e:
        _safe_print(f"[AUTH] Online check error: {e}")

    # Offline grace period check
    grace_until = sess.get("offline_grace_until", 0)
    if time.time() < grace_until:
        remaining_hrs = (grace_until - time.time()) / 3600
        _safe_print(f"[AUTH] Offline mode — grace: {remaining_hrs:.1f}h remaining")
        return sess

    # Grace period bhi khatam
    _safe_print("[AUTH] Offline grace expired — session invalid")
    _clear_session()
    return None


def do_heartbeat(session_token: str, machine_hash: str) -> dict | None:
    """
    Silent heartbeat — fresh JWT token + current plan fields return karta hai.

    Backend ab best current license re-resolve karke
    ``{session_token, plan, plan_label, is_premium, expires_at, status}`` deta hai,
    isliye heartbeat sirf token refresh nahi karta — plan change (Free → Pro/Studio,
    ya expiry) bhi pick karta hai aur cached session mein persist karta hai.

    Args:
        session_token: Current JWT token
        machine_hash:  Machine fingerprint

    Returns:
        dict ``{token, plan, plan_label, is_premium, expires_at}`` on success,
        ya None agar license invalid/expired. (Network/temporary error pe current
        token ke saath dict, taake caller session na giraye.)
    """
    if not session_token:
        return None

    def _result(tok, flat=None):
        flat = flat or {}
        return {
            "token":      tok,
            "plan":       flat.get("plan", "") or "",
            "plan_label": flat.get("plan_label", "") or "",
            "is_premium": flat.get("is_premium", None),
            "expires_at": flat.get("expires_at", "") or "",
        }

    try:
        result = _http_post(
            "/api/addon-license/heartbeat",
            {"machine_hash": machine_hash},
            token=session_token,
            timeout=8,
        )
        flat = _extract_plan_fields(result)
        new_token = flat["session_token"] or session_token

        # Update saved session — token + plan fields dono.
        sess = _load_session()
        if sess:
            sess["session_token"] = new_token
            _apply_plan_fields(sess, flat)
            sess["offline_grace_until"] = time.time() + OFFLINE_GRACE_HRS * 3600
            _save_session(sess)

        return _result(new_token, flat)

    except urllib.error.HTTPError as e:
        if e.code in (401, 402, 403):
            _safe_print(f"[AUTH] Heartbeat rejected ({e.code}) — license invalid")
            _clear_session()
            return None
        _safe_print(f"[AUTH] Heartbeat HTTP {e.code} — returning current token")
        return _result(session_token)  # Temporary error, keep current token

    except Exception:
        # Offline ya network error — same token wapas do
        return _result(session_token)


def deactivate_license(session_token: str):
    """
    Machine ki binding hatao — doosri machine pe activate ho sake.

    Args:
        session_token: Current JWT token
    """
    try:
        _http_post(
            "/api/addon-license/deactivate",
            {},
            token=session_token,
            timeout=8,
        )
        _safe_print("[AUTH] License deactivated on server")
    except Exception as e:
        _safe_print(f"[AUTH] Deactivate server call failed: {e}")
    finally:
        _clear_session()
        _safe_print("[AUTH] Local session cleared")


def proxy_get(path: str, params: dict, session_token: str) -> dict:
    """
    Strapi proxy se API data fetch karo.
    Real cgheven API token kabhi client nahi dekhta.

    Args:
        path:          Endpoint path (e.g. "assets", "categories")
        params:        Query parameters dict
        session_token: Current JWT token

    Returns:
        API response dict
    """
    clean_path = path.lstrip("/")
    return _http_get(
        f"/api/proxy/{clean_path}",
        params=params,
        token=session_token,
        timeout=15,
    )


# ══════════════════════════════════════════════════════════════
#  CUSTOM EXCEPTION
# ══════════════════════════════════════════════════════════════

class LicenseError(Exception):
    """License-related errors with HTTP code."""

    def __init__(self, message: str = "", code: int = 400):
        super().__init__(message)
        self.code = code

    def is_expired(self) -> bool:
        """Tier cancel ya payment fail."""
        return self.code == 402

    def is_revoked(self) -> bool:
        """Admin ne manually revoke kiya."""
        return self.code == 403

    def is_device_limit(self) -> bool:
        """Already activated on another device."""
        return self.code == 403

    def is_not_found(self) -> bool:
        """Invalid key — DB mein nahi mila."""
        return self.code in (401, 404)

    def is_network_error(self) -> bool:
        """Server tak pahunch nahi paye."""
        return self.code == 0


# ══════════════════════════════════════════════════════════════
#  QUICK TEST — directly run karo to test connection
#  python cgheven_auth.py
# ══════════════════════════════════════════════════════════════

if __name__ == "__main__":
    _safe_print("=" * 50)
    _safe_print("CGHEVEN Auth Module — Connection Test")
    _safe_print("=" * 50)
    _safe_print(f"Server      : {LICENSE_SERVER}")
    _safe_print(f"Addon Type  : {ADDON_TYPE}")
    _safe_print(f"Machine Hash: {get_machine_fingerprint()[:16]}...")
    _safe_print(f"Machine     : {get_machine_label()}")
    _safe_print(f"Session File: {SESSION_FILE}")
    _safe_print()

    # Test server connection
    # try:
    #     req = urllib.request.Request(
    #         # f"{LICENSE_SERVER}/health",
    #           f"{LICENSE_SERVER}/api/addon-license/activate",
    #         headers={"User-Agent": "CGHEVEN-Test/1.0"}
    #     )
    #     with urllib.request.urlopen(req, timeout=5) as r:
    #         data = json.loads(r.read().decode())
    #         _safe_print(f"Server Status : ONLINE ✅  ({data})")
    # except Exception as e:
    #     _safe_print(f"Server Status : OFFLINE ❌  ({e})")


    try:
        req = urllib.request.Request(
            f"{LICENSE_SERVER}/api/addon-license/activate",
            headers={"User-Agent": "CGHEVEN-Test/1.0"}
        )
        with urllib.request.urlopen(req, timeout=5) as r:
            _safe_print(f"Server Status : ONLINE ✅")
    except urllib.error.HTTPError as e:
        # 404/405 = endpoint exist karta hai — server ONLINE hai
        if e.code in (404, 405, 400, 401, 403):
            _safe_print(f"Server Status : ONLINE ✅  (HTTP {e.code} — server responding)")
        else:
            _safe_print(f"Server Status : OFFLINE ❌  (HTTP {e.code})")
    except Exception as e:
        _safe_print(f"Server Status : OFFLINE ❌  ({e})")

    # Test cached session
    _safe_print()
    sess = _load_session()
    if sess:
        _safe_print(f"Cached Session: FOUND ✅")
        _safe_print(f"  Plan        : {sess.get('plan', 'N/A')}")
        _safe_print(f"  Machine OK  : {sess.get('machine_hash') == get_machine_fingerprint()}")
        grace = sess.get("offline_grace_until", 0)
        if time.time() < grace:
            hrs = (grace - time.time()) / 3600
            _safe_print(f"  Grace Left  : {hrs:.1f} hours")
    else:
        _safe_print("Cached Session: NONE — activate karo pehle")

    _safe_print()
    _safe_print("Test complete.")