#!/usr/bin/env python3
"""
Genesis Upgrade Watcher
=======================
Monitors Claude Code, MCP packages, and Telnyx models for new releases.
Writes results to E:/genesis-system/data/upgrade_status.json
Sends webhook notification if anything new is found.

Runtime target: < 30 seconds
Author: Genesis Build Agent
Created: 2026-02-23
"""

import json
import subprocess
import urllib.request
import urllib.error
import sys
import os
import time
from datetime import datetime, timezone
from typing import Optional

# ──────────────────────────────────────────────────────────────────────────────
# CONFIGURATION
# ──────────────────────────────────────────────────────────────────────────────

GENESIS_ROOT = r"E:\genesis-system"
DATA_DIR = os.path.join(GENESIS_ROOT, "data")
LOGS_DIR = os.path.join(GENESIS_ROOT, "logs")

KNOWN_MODELS_FILE = os.path.join(DATA_DIR, "known_telnyx_models.json")
UPGRADE_STATUS_FILE = os.path.join(DATA_DIR, "upgrade_status.json")

TELNYX_API_KEY = "KEY019BE7A3A2D749FCA8681CFF8448A7F0_vTMM1n77CtQxLDT2ra3P1z"
TELNYX_MODELS_URL = "https://api.telnyx.com/v2/ai/models"

NOTIFICATION_WEBHOOK = "https://api.sunaivadigital.com/webhook/genesis-upgrade-alert"

# npm packages to monitor for MCP servers
MCP_PACKAGES = [
    "@anthropic-ai/claude-code",
    "@modelcontextprotocol/server-filesystem",
    "@modelcontextprotocol/server-github",
    "@modelcontextprotocol/server-postgres",
    "@modelcontextprotocol/server-slack",
    "@modelcontextprotocol/server-brave-search",
    "@modelcontextprotocol/server-google-drive",
    "@modelcontextprotocol/server-memory",
]

CLAUDE_CODE_PACKAGE = "@anthropic-ai/claude-code"

# Auto-install policy:
# - patch version bumps (x.y.Z) → auto-install
# - minor/major version bumps → flag for Kinan approval only
AUTO_INSTALL_PATCH_ONLY = True


# ──────────────────────────────────────────────────────────────────────────────
# LOGGING
# ──────────────────────────────────────────────────────────────────────────────

def log(msg: str, level: str = "INFO"):
    ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    line = f"[{ts}] [{level}] {msg}"
    print(line)
    # Also append to log file
    log_file = os.path.join(LOGS_DIR, "upgrade_watcher.log")
    try:
        with open(log_file, "a", encoding="utf-8") as f:
            f.write(line + "\n")
    except Exception:
        pass  # Don't crash if log write fails


def ensure_dirs():
    os.makedirs(DATA_DIR, exist_ok=True)
    os.makedirs(LOGS_DIR, exist_ok=True)


# ──────────────────────────────────────────────────────────────────────────────
# VERSION UTILITIES
# ──────────────────────────────────────────────────────────────────────────────

def parse_semver(version_str: str) -> tuple:
    """Parse 'x.y.z' into (major, minor, patch) integers. Returns (0,0,0) on failure."""
    try:
        clean = version_str.strip().lstrip("v")
        parts = clean.split(".")
        major = int(parts[0]) if len(parts) > 0 else 0
        minor = int(parts[1]) if len(parts) > 1 else 0
        patch = int(parts[2].split("-")[0]) if len(parts) > 2 else 0
        return (major, minor, patch)
    except Exception:
        return (0, 0, 0)


def is_patch_only(current: str, latest: str) -> bool:
    """Return True only if the update is a patch-level bump (same major.minor)."""
    c = parse_semver(current)
    l = parse_semver(latest)
    return c[0] == l[0] and c[1] == l[1] and l[2] > c[2]


def is_newer(current: str, latest: str) -> bool:
    """Return True if latest > current."""
    c = parse_semver(current)
    l = parse_semver(latest)
    return l > c


# ──────────────────────────────────────────────────────────────────────────────
# NPM CHECKS
# ──────────────────────────────────────────────────────────────────────────────

def get_installed_npm_version(package: str) -> Optional[str]:
    """Get the globally installed version of an npm package."""
    try:
        result = subprocess.run(
            ["npm", "list", "-g", package, "--json", "--depth=0"],
            capture_output=True, text=True, timeout=15
        )
        data = json.loads(result.stdout or "{}")
        deps = data.get("dependencies", {})
        if package in deps:
            return deps[package].get("version")
        return None
    except Exception as e:
        log(f"Could not get installed version of {package}: {e}", "WARN")
        return None


def get_latest_npm_version(package: str) -> Optional[str]:
    """Get the latest published version of an npm package."""
    try:
        result = subprocess.run(
            ["npm", "view", package, "version"],
            capture_output=True, text=True, timeout=15
        )
        version = result.stdout.strip()
        return version if version else None
    except Exception as e:
        log(f"Could not fetch latest version of {package}: {e}", "WARN")
        return None


def check_claude_code() -> dict:
    """Check Claude Code for updates. Returns result dict."""
    log("Checking Claude Code version...")
    pkg = CLAUDE_CODE_PACKAGE

    installed = get_installed_npm_version(pkg)
    latest = get_latest_npm_version(pkg)

    result = {
        "package": pkg,
        "installed": installed,
        "latest": latest,
        "has_update": False,
        "update_type": None,
        "action_taken": None,
        "requires_approval": False,
        "error": None,
    }

    if not latest:
        result["error"] = "Could not fetch latest version from npm"
        log(f"  Could not fetch latest version for {pkg}", "WARN")
        return result

    if not installed:
        log(f"  {pkg} not installed globally")
        result["installed"] = "not-installed"
        result["has_update"] = True
        result["update_type"] = "new-install"
        result["requires_approval"] = True
        return result

    if is_newer(installed, latest):
        result["has_update"] = True
        patch_only = is_patch_only(installed, latest)
        result["update_type"] = "patch" if patch_only else "minor-or-major"

        log(f"  UPDATE FOUND: {pkg} {installed} -> {latest} ({result['update_type']})")

        if patch_only and AUTO_INSTALL_PATCH_ONLY:
            log(f"  Auto-installing patch update...")
            try:
                install_result = subprocess.run(
                    ["npm", "install", "-g", f"{pkg}@{latest}"],
                    capture_output=True, text=True, timeout=60
                )
                if install_result.returncode == 0:
                    result["action_taken"] = f"auto-installed-{latest}"
                    log(f"  Auto-install SUCCESS: {pkg}@{latest}")
                else:
                    result["action_taken"] = "auto-install-failed"
                    result["error"] = install_result.stderr[:500]
                    log(f"  Auto-install FAILED: {install_result.stderr[:200]}", "ERROR")
            except Exception as e:
                result["action_taken"] = "auto-install-exception"
                result["error"] = str(e)
                log(f"  Auto-install exception: {e}", "ERROR")
        else:
            result["requires_approval"] = True
            result["action_taken"] = "flagged-for-kinan"
            log(f"  Flagged for Kinan approval (non-patch update)")
    else:
        log(f"  {pkg} is up to date ({installed})")

    return result


def check_mcp_packages() -> list:
    """Check all MCP packages for updates. Returns list of result dicts."""
    log("Checking MCP packages...")
    results = []
    for pkg in MCP_PACKAGES:
        if pkg == CLAUDE_CODE_PACKAGE:
            continue  # Already checked above
        installed = get_installed_npm_version(pkg)
        latest = get_latest_npm_version(pkg)

        item = {
            "package": pkg,
            "installed": installed or "not-installed",
            "latest": latest or "unknown",
            "has_update": False,
            "update_type": None,
            "requires_approval": False,
        }

        if installed and latest and is_newer(installed, latest):
            item["has_update"] = True
            item["update_type"] = "patch" if is_patch_only(installed, latest) else "minor-or-major"
            item["requires_approval"] = not is_patch_only(installed, latest)
            log(f"  UPDATE: {pkg} {installed} -> {latest}")
        elif not installed:
            log(f"  {pkg}: not installed")
        else:
            log(f"  {pkg}: up to date ({installed})")

        results.append(item)
    return results


# ──────────────────────────────────────────────────────────────────────────────
# TELNYX MODEL CHECK
# ──────────────────────────────────────────────────────────────────────────────

def fetch_telnyx_models() -> Optional[list]:
    """Fetch current model list from Telnyx API. Returns list of model id strings."""
    try:
        req = urllib.request.Request(
            TELNYX_MODELS_URL,
            headers={
                "Authorization": f"Bearer {TELNYX_API_KEY}",
                "Content-Type": "application/json",
            }
        )
        with urllib.request.urlopen(req, timeout=15) as resp:
            raw = resp.read().decode("utf-8")
            data = json.loads(raw)

        # Telnyx API returns {"data": [...]} where each item has an "id" field
        # Some versions use a flat list, some use {"data": [...]}
        if isinstance(data, dict) and "data" in data:
            items = data["data"]
        elif isinstance(data, list):
            items = data
        else:
            log(f"Unexpected Telnyx API response structure: {list(data.keys())}", "WARN")
            return None

        model_ids = []
        for item in items:
            if isinstance(item, dict):
                mid = item.get("id") or item.get("model_id") or item.get("name")
                if mid:
                    model_ids.append(mid)
            elif isinstance(item, str):
                model_ids.append(item)

        log(f"  Telnyx API returned {len(model_ids)} models")
        return model_ids

    except urllib.error.HTTPError as e:
        log(f"  Telnyx API HTTP error {e.code}: {e.reason}", "ERROR")
        return None
    except Exception as e:
        log(f"  Telnyx API fetch error: {e}", "ERROR")
        return None


def load_known_models() -> dict:
    """Load the known models cache file."""
    try:
        with open(KNOWN_MODELS_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception as e:
        log(f"Could not load known models file: {e}", "WARN")
        return {"models": [], "last_updated": None, "model_count": 0}


def save_known_models(known_data: dict, live_model_ids: list):
    """Update the known models cache with newly discovered models."""
    existing_ids = {m["id"] for m in known_data.get("models", [])}
    new_entries = []
    for mid in live_model_ids:
        if mid not in existing_ids:
            # Parse provider from id (format: "provider/model-name")
            parts = mid.split("/", 1)
            provider = parts[0] if len(parts) > 1 else "unknown"
            family = parts[1].split("-")[0].lower() if len(parts) > 1 else "unknown"
            new_entries.append({"id": mid, "provider": provider, "family": family})

    if new_entries:
        known_data["models"].extend(new_entries)
        known_data["model_count"] = len(known_data["models"])
        known_data["last_updated"] = datetime.now(timezone.utc).isoformat()
        try:
            with open(KNOWN_MODELS_FILE, "w", encoding="utf-8") as f:
                json.dump(known_data, f, indent=2)
            log(f"  Updated known_telnyx_models.json with {len(new_entries)} new models")
        except Exception as e:
            log(f"  Failed to write known models cache: {e}", "ERROR")


def check_telnyx_models() -> dict:
    """Check Telnyx API for new models not in the known-models cache."""
    log("Checking Telnyx models...")
    result = {
        "known_count": 0,
        "live_count": 0,
        "new_models": [],
        "has_new": False,
        "error": None,
    }

    known_data = load_known_models()
    known_ids = {m["id"] for m in known_data.get("models", [])}
    result["known_count"] = len(known_ids)

    live_ids = fetch_telnyx_models()
    if live_ids is None:
        result["error"] = "Failed to fetch from Telnyx API"
        return result

    result["live_count"] = len(live_ids)

    new_models = [mid for mid in live_ids if mid not in known_ids]
    result["new_models"] = new_models
    result["has_new"] = len(new_models) > 0

    if new_models:
        log(f"  NEW TELNYX MODELS FOUND: {new_models}")
        # Update cache
        save_known_models(known_data, live_ids)
    else:
        log(f"  No new Telnyx models (known: {len(known_ids)}, live: {len(live_ids)})")

    return result


# ──────────────────────────────────────────────────────────────────────────────
# NOTIFICATION
# ──────────────────────────────────────────────────────────────────────────────

def send_notification(status: dict):
    """POST update summary to Genesis webhook."""
    payload = {
        "source": "genesis-upgrade-watcher",
        "timestamp": status["checked_at"],
        "has_updates": status["has_updates"],
        "summary": build_summary(status),
        "details": status,
    }
    body = json.dumps(payload).encode("utf-8")
    try:
        req = urllib.request.Request(
            NOTIFICATION_WEBHOOK,
            data=body,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            log(f"Notification sent (HTTP {resp.status})")
    except Exception as e:
        log(f"Notification failed (non-fatal): {e}", "WARN")


def build_summary(status: dict) -> str:
    """Build a human-readable summary string."""
    lines = []

    cc = status.get("claude_code", {})
    if cc.get("has_update"):
        action = cc.get("action_taken", "unknown")
        lines.append(
            f"Claude Code: {cc.get('installed')} -> {cc.get('latest')} "
            f"[{cc.get('update_type')}] action={action}"
        )

    for pkg in status.get("mcp_packages", []):
        if pkg.get("has_update"):
            lines.append(
                f"MCP {pkg['package']}: {pkg['installed']} -> {pkg['latest']} "
                f"[{pkg.get('update_type')}]"
            )

    telnyx = status.get("telnyx_models", {})
    if telnyx.get("has_new"):
        lines.append(
            f"Telnyx new models ({len(telnyx['new_models'])}): "
            + ", ".join(telnyx["new_models"])
        )

    if not lines:
        lines.append("All systems up to date. No updates found.")

    return " | ".join(lines)


# ──────────────────────────────────────────────────────────────────────────────
# MAIN
# ──────────────────────────────────────────────────────────────────────────────

def main():
    start = time.time()
    ensure_dirs()

    log("=" * 60)
    log("Genesis Upgrade Watcher — starting run")
    log("=" * 60)

    status = {
        "checked_at": datetime.now(timezone.utc).isoformat(),
        "has_updates": False,
        "requires_kinan_approval": False,
        "claude_code": {},
        "mcp_packages": [],
        "telnyx_models": {},
        "runtime_seconds": 0,
        "summary": "",
    }

    # 1. Check Claude Code
    cc_result = check_claude_code()
    status["claude_code"] = cc_result
    if cc_result.get("has_update"):
        status["has_updates"] = True
    if cc_result.get("requires_approval"):
        status["requires_kinan_approval"] = True

    # 2. Check MCP packages
    mcp_results = check_mcp_packages()
    status["mcp_packages"] = mcp_results
    for pkg in mcp_results:
        if pkg.get("has_update"):
            status["has_updates"] = True
        if pkg.get("requires_approval"):
            status["requires_kinan_approval"] = True

    # 3. Check Telnyx models
    telnyx_result = check_telnyx_models()
    status["telnyx_models"] = telnyx_result
    if telnyx_result.get("has_new"):
        status["has_updates"] = True

    # Build summary
    status["summary"] = build_summary(status)
    status["runtime_seconds"] = round(time.time() - start, 2)

    # Write status file
    try:
        with open(UPGRADE_STATUS_FILE, "w", encoding="utf-8") as f:
            json.dump(status, f, indent=2)
        log(f"Wrote upgrade_status.json")
    except Exception as e:
        log(f"Failed to write upgrade_status.json: {e}", "ERROR")

    # Send notification only if updates found
    if status["has_updates"]:
        log("Updates detected — sending webhook notification...")
        send_notification(status)
    else:
        log("No updates found — skipping notification")

    log("=" * 60)
    log(f"Run complete in {status['runtime_seconds']}s | has_updates={status['has_updates']}")
    log(f"Summary: {status['summary']}")
    log("=" * 60)

    # Exit code 0 = success, 2 = updates found (for n8n IF node)
    sys.exit(2 if status["has_updates"] else 0)


if __name__ == "__main__":
    main()
