#!/usr/bin/env python3
"""UClaude Updater — automatic Claude Code patch updater.

Usage:
    sudo python3 uclaude_updater.py              # Check and update if new version available
    sudo python3 uclaude_updater.py --check      # Only check, don't install
    sudo python3 uclaude_updater.py --force       # Update even if version matches
    sudo python3 uclaude_updater.py --settings-only  # Only patch settings, don't touch cli.js
"""

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time

# ============================================================
#  Platform detection
# ============================================================

IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"

try:
    import pwd
    HAS_PWD = True
except ImportError:
    HAS_PWD = False

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))  # claude/
REPO_ROOT = os.path.dirname(SCRIPT_DIR)                   # unlimitedcoding/

# ANSI colors
G = "\033[92m"   # green
Y = "\033[93m"   # yellow
R = "\033[91m"   # red
W = "\033[97m"   # white/bold
D = "\033[0m"    # default


# ============================================================
#  Helpers
# ============================================================

def is_admin():
    if IS_WINDOWS:
        try:
            import ctypes
            return ctypes.windll.shell32.IsUserAnAdmin() != 0
        except Exception:
            return False
    return os.geteuid() == 0


def safe_chown(path, uid, gid):
    if not IS_WINDOWS and is_admin():
        os.chown(path, uid, gid)


def safe_chmod(path, mode):
    if not IS_WINDOWS:
        os.chmod(path, mode)


def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)


def run_cmd(cmd, **kwargs):
    """run_cmd wrapper: uses shell=True on Windows for .cmd/.bat commands (npm, claude, etc.)."""
    if IS_WINDOWS:
        kwargs.setdefault("shell", True)
    return subprocess.run(cmd, **kwargs)


# ============================================================
#  Node.js check and auto-install
# ============================================================

MIN_NODE_VERSION = (18, 0, 0)


def get_node_version():
    """Get installed Node.js version as tuple, or None."""
    try:
        result = run_cmd(
            ["node", "--version"],
            capture_output=True, text=True, timeout=10,
        )
        m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", result.stdout.strip())
        if m:
            return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    return None


def install_node():
    """Auto-install Node.js v24+ using the official nodesource setup (Linux) or brew (macOS)."""
    print(f"  {Y}Node.js v{'.'.join(map(str, MIN_NODE_VERSION))}+ required.{D}")

    if IS_WINDOWS:
        print(f"  {R}Please install Node.js manually: https://nodejs.org/{D}")
        print(f"  Or run: winget install OpenJS.NodeJS")
        return False

    if IS_MACOS:
        print(f"  Installing Node.js via Homebrew...")
        try:
            result = run_cmd(
                ["brew", "install", "node"],
                timeout=120, capture_output=True, text=True,
            )
            if result.returncode == 0:
                ver = get_node_version()
                if ver and ver >= MIN_NODE_VERSION:
                    print(f"  {G}Node.js v{'.'.join(map(str, ver))} installed{D}")
                    return True
        except FileNotFoundError:
            pass
        eprint(f"  {R}Install Homebrew first: https://brew.sh/ then: brew install node{D}")
        return False

    # Linux — nodesource
    print(f"  Installing Node.js LTS via nodesource...")
    try:
        # Remove old nodesource list if present (prevents version conflicts)
        for old_list in ["/etc/apt/sources.list.d/nodesource.list"]:
            if os.path.isfile(old_list):
                os.remove(old_list)

        result = run_cmd(
            ["bash", "-c", "curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get remove -y nodejs || true && apt-get install -y nodejs"],
            timeout=180, capture_output=True, text=True,
        )
        if result.returncode == 0:
            ver = get_node_version()
            if ver and ver >= MIN_NODE_VERSION:
                print(f"  {G}Node.js v{'.'.join(map(str, ver))} installed{D}")
                return True
            elif ver:
                eprint(f"  {Y}nodesource installed v{'.'.join(map(str, ver))} but need v{'.'.join(map(str, MIN_NODE_VERSION))}+{D}")

        # Try dnf/yum fallback for RHEL/Fedora
        for pkg_mgr in ["dnf", "yum"]:
            if shutil.which(pkg_mgr):
                result = run_cmd(
                    ["bash", "-c", f"curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && {pkg_mgr} install -y nodejs"],
                    timeout=180, capture_output=True, text=True,
                )
                if result.returncode == 0:
                    ver = get_node_version()
                    if ver and ver >= MIN_NODE_VERSION:
                        print(f"  {G}Node.js v{'.'.join(map(str, ver))} installed{D}")
                        return True
                    elif ver:
                        eprint(f"  {Y}Installed v{'.'.join(map(str, ver))} but need v{'.'.join(map(str, MIN_NODE_VERSION))}+{D}")
                break

        eprint(f"  {R}Auto-install failed. Install Node.js v{'.'.join(map(str, MIN_NODE_VERSION))}+ manually: https://nodejs.org/{D}")
        if result.stderr:
            eprint(f"  {result.stderr.strip()[:200]}")
        return False
    except Exception as e:
        eprint(f"  {R}Auto-install error: {e}{D}")
        return False


def ensure_node():
    """Check Node.js version, auto-install/update if needed. Returns True if OK."""
    ver = get_node_version()

    if ver is None:
        print(f"  {Y}Node.js not found.{D}")
        if is_admin():
            ok = install_node()
            if ok:
                # Re-verify after install — PATH may now point to new binary
                ver = get_node_version()
                if ver and ver >= MIN_NODE_VERSION:
                    return True
                eprint(f"  {R}Node.js still not available after install. Reopen shell or check PATH.{D}")
                return False
            return False
        else:
            eprint(f"  {R}Install Node.js v{'.'.join(map(str, MIN_NODE_VERSION))}+: https://nodejs.org/{D}")
            return False

    if ver < MIN_NODE_VERSION:
        print(f"  {Y}Node.js v{'.'.join(map(str, ver))} found, need v{'.'.join(map(str, MIN_NODE_VERSION))}+{D}")
        if is_admin():
            ok = install_node()
            if ok:
                # Re-verify after upgrade — PATH may now point to new binary
                ver = get_node_version()
                if ver and ver >= MIN_NODE_VERSION:
                    return True
                eprint(f"  {R}Node.js version still insufficient after upgrade. Reopen shell or check PATH.{D}")
                return False
            return False
        else:
            eprint(f"  {R}Update Node.js: https://nodejs.org/{D}")
            return False

    return True


# ============================================================
#  Claude Code auto-install
# ============================================================

def ensure_claude_code(target_version=None):
    """Install or update Claude Code via npm. Returns True if OK.

    If target_version is set and the installed version doesn't match,
    reinstall to the exact version so cli.js patch is compatible.
    """
    all_paths = find_all_cli_js()
    cli_js = all_paths[0] if all_paths else None

    # If already installed, check version compatibility
    if cli_js and target_version:
        installed_ver, _ = get_installed_version()
        if installed_ver and ver_tuple(installed_ver) >= ver_tuple(target_version):
            return True
        # Version mismatch — need to update npm package to match patched cli.js
        print(f"  {Y}Claude Code {installed_ver} installed, need {target_version} for patch compatibility{D}")
        print(f"  Updating npm package to v{target_version}...")
        pkg = f"@anthropic-ai/claude-code@{target_version}"
    elif cli_js:
        return True
    else:
        print(f"  {Y}Claude Code not found. Installing via npm...{D}")
        pkg = "@anthropic-ai/claude-code" + (f"@{target_version}" if target_version else "")

    try:
        result = run_cmd(
            ["npm", "install", "-g", pkg],
            capture_output=True, text=True, timeout=300,
        )
        if result.returncode == 0:
            found = find_all_cli_js()
            if found:
                new_ver, _ = get_installed_version()
                print(f"  {G}Claude Code {new_ver or ''} installed{D}")
                return True

        eprint(f"  {R}npm install failed{D}")
        if result.stderr:
            eprint(f"  {result.stderr.strip()[:300]}")
        return False
    except FileNotFoundError:
        eprint(f"  {R}npm not found. Install Node.js first.{D}")
        return False
    except subprocess.TimeoutExpired:
        eprint(f"  {R}npm install timed out{D}")
        return False
    except Exception as e:
        eprint(f"  {R}Error: {e}{D}")
        return False


# ============================================================
#  Version detection
# ============================================================

def find_cli_js():
    """Find installed Claude Code cli.js path."""
    candidates = []
    if IS_WINDOWS:
        for env_key in ("APPDATA", "LOCALAPPDATA", "PROGRAMFILES"):
            base = os.environ.get(env_key, "")
            if base:
                candidates.append(os.path.join(base, "npm", "node_modules", "@anthropic-ai", "claude-code", "cli.js"))
    else:
        candidates = [
            "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js",
            "/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js",
            "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js",
        ]

    # Prepend npm root -g result (most reliable, works across all Node install methods)
    try:
        result = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=10)
        if result.returncode == 0:
            npm_global = result.stdout.strip()
            candidates.insert(0, os.path.join(npm_global, "@anthropic-ai", "claude-code", "cli.js"))
    except Exception:
        pass

    for path in candidates:
        if os.path.isfile(path):
            return path
    return None


def find_all_cli_js():
    """Find ALL installed Claude Code cli.js paths (for multi-install patching)."""
    candidates = set()

    if IS_WINDOWS:
        for env_key in ("APPDATA", "LOCALAPPDATA", "PROGRAMFILES"):
            base = os.environ.get(env_key, "")
            if base:
                candidates.add(os.path.join(base, "npm", "node_modules",
                               "@anthropic-ai", "claude-code", "cli.js"))
    else:
        # Static well-known paths
        for prefix in ("/usr/lib", "/usr/local/lib", "/opt/homebrew/lib"):
            candidates.add(os.path.join(prefix, "node_modules",
                           "@anthropic-ai", "claude-code", "cli.js"))

        # npm root -g (primary install path)
        try:
            r = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=10)
            if r.returncode == 0:
                candidates.add(os.path.join(r.stdout.strip(),
                               "@anthropic-ai", "claude-code", "cli.js"))
        except Exception:
            pass

        # Resolve `which claude` → follow symlinks → find cli.js
        try:
            r = subprocess.run(["which", "claude"], capture_output=True, text=True, timeout=5)
            if r.returncode == 0:
                claude_bin = os.path.realpath(r.stdout.strip())
                if os.path.basename(claude_bin) == "cli.js":
                    # which claude resolves directly to cli.js
                    candidates.add(claude_bin)
                else:
                    # which claude points to .bin/claude wrapper
                    # cli.js is at node_modules/@anthropic-ai/claude-code/cli.js
                    nm = os.path.dirname(os.path.dirname(claude_bin))  # node_modules/
                    candidates.add(os.path.join(nm, "@anthropic-ai", "claude-code", "cli.js"))
        except Exception:
            pass

        # NVM installs: /root/.nvm, /home/*/.nvm
        nvm_bases = ["/root/.nvm"]
        if os.path.isdir("/home"):
            for user in os.listdir("/home"):
                nvm_bases.append(f"/home/{user}/.nvm")
        for nvm_base in nvm_bases:
            versions_dir = os.path.join(nvm_base, "versions", "node")
            if os.path.isdir(versions_dir):
                for node_ver in os.listdir(versions_dir):
                    candidates.add(os.path.join(versions_dir, node_ver, "lib",
                                   "node_modules", "@anthropic-ai", "claude-code", "cli.js"))

    return [p for p in candidates if os.path.isfile(p)]


def get_installed_version():
    """Get currently installed Claude Code version.

    Priority: cli.js bundle version > claude --version > package.json.
    After patching, cli.js contains the real version while package.json
    may still reflect the older npm-installed version.
    """
    cli_js = find_cli_js()
    if not cli_js:
        return None, None

    # 1. Extract version from cli.js bundle itself (most accurate after patching)
    try:
        with open(cli_js, "r", encoding="utf-8", errors="ignore") as f:
            # Read first 100KB where version string usually lives
            head = f.read(100_000)
        # Look for "// Version: x.y.z" comment or VERSION:"x.y.z" in the bundle
        m = re.search(r'//\s*Version:\s*(\d+\.\d+\.\d+)', head)
        if not m:
            m = re.search(r'(?:VERSION|version)\s*[:=]\s*["\'](\d+\.\d+\.\d+)["\']', head)
        if m:
            return m.group(1), cli_js
    except Exception:
        pass

    # 2. claude --version
    try:
        result = run_cmd(
            ["claude", "--version"],
            capture_output=True, text=True, timeout=10,
        )
        m = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
        if m:
            return m.group(1), cli_js
    except Exception:
        pass

    # 3. Fallback: package.json (may be stale after cli.js replacement)
    pkg_json = os.path.join(os.path.dirname(cli_js), "package.json")
    if os.path.isfile(pkg_json):
        try:
            with open(pkg_json, "r") as f:
                data = json.load(f)
            return data.get("version"), cli_js
        except Exception:
            pass

    return None, cli_js


def get_latest_version():
    """Read latest version from local index.json."""
    index_path = os.path.join(SCRIPT_DIR, "releases", "index.json")
    if not os.path.isfile(index_path):
        return None
    try:
        with open(index_path, "r") as f:
            data = json.load(f)
        return data.get("latest")
    except Exception:
        return None


def ver_tuple(v):
    """Parse version string to tuple for comparison."""
    m = re.match(r"(\d+)\.(\d+)\.(\d+)", v or "")
    return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)


# Markers left by the patcher in cli.js — if any is missing, cli.js is not patched
PATCH_MARKERS = [
    "__CLAUDE_SETTINGS__",
    "/*bypass_permissions_prompt*/",
    "/* root check removed by patcher */",
]


def is_patched(cli_js_path):
    """Check if cli.js has patch markers. Returns (patched: bool, missing: list)."""
    if not cli_js_path or not os.path.isfile(cli_js_path):
        return False, PATCH_MARKERS[:]
    try:
        with open(cli_js_path, "r", encoding="utf-8", errors="ignore") as f:
            content = f.read()
    except Exception:
        return False, PATCH_MARKERS[:]
    missing = [m for m in PATCH_MARKERS if m not in content]
    return len(missing) == 0, missing


# ============================================================
#  Git pull
# ============================================================

def git_pull():
    """Pull latest changes from remote (shallow fetch for minimal download)."""
    try:
        # Shallow fetch + reset — downloads only latest commit, not full history
        result = run_cmd(
            ["git", "fetch", "--depth", "1", "origin", "master"],
            cwd=REPO_ROOT, capture_output=True, text=True, timeout=60,
        )
        if result.returncode != 0:
            # Fallback to regular pull
            result = run_cmd(
                ["git", "pull", "--quiet"],
                cwd=REPO_ROOT, capture_output=True, text=True, timeout=60,
            )
            if result.returncode != 0:
                eprint(f"  {Y}git pull warning: {result.stderr.strip()}{D}")
            return True

        # Reset to fetched state
        run_cmd(
            ["git", "reset", "--hard", "origin/master"],
            cwd=REPO_ROOT, capture_output=True, text=True, timeout=10,
        )

        # Setup sparse checkout to download only latest version's cli.js
        _setup_sparse_checkout()

        return True
    except subprocess.TimeoutExpired:
        eprint(f"  {Y}git fetch timed out{D}")
        return True
    except FileNotFoundError:
        eprint(f"  {Y}git not found, skipping pull{D}")
        return True


def _setup_sparse_checkout():
    """Configure sparse checkout to only include root files + latest release.

    This avoids downloading cli.js for ALL versions (each ~12MB).
    Only the latest version's cli.js is checked out.
    """
    index_path = os.path.join(SCRIPT_DIR, "releases", "index.json")
    if not os.path.isfile(index_path):
        return

    try:
        with open(index_path, "r") as f:
            latest = json.load(f).get("latest")
    except Exception:
        return

    if not latest:
        return

    # Enable sparse checkout
    run_cmd(
        ["git", "config", "core.sparseCheckout", "true"],
        cwd=REPO_ROOT, capture_output=True,
    )

    sparse_file = os.path.join(REPO_ROOT, ".git", "info", "sparse-checkout")
    os.makedirs(os.path.dirname(sparse_file), exist_ok=True)

    patterns = [
        "/*",                                          # root files (updater, config, README)
        "/claude/releases/index.json",                 # version index
        f"/claude/releases/v{latest}/",                # latest release (cli.js + changelog + install)
    ]

    with open(sparse_file, "w") as f:
        f.write("\n".join(patterns) + "\n")

    # Apply sparse checkout
    run_cmd(
        ["git", "checkout", "HEAD", "--", "."],
        cwd=REPO_ROOT, capture_output=True, timeout=30,
    )


# ============================================================
#  CLI.js installation
# ============================================================

def install_cli_js(version, cli_js_path):
    """Install patched cli.js for the given version."""
    release_cli = os.path.join(SCRIPT_DIR, "releases", f"v{version}", "cli.js")
    if not os.path.isfile(release_cli):
        eprint(f"  {R}Release cli.js not found: {release_cli}{D}")
        return False

    # Backup
    timestamp = time.strftime("%Y%m%d%H%M%S")
    backup_path = f"{cli_js_path}.bak.{timestamp}"

    try:
        shutil.copy2(cli_js_path, backup_path)
        shutil.copy2(release_cli, cli_js_path)

        # Ensure execute permission (755)
        os.chmod(cli_js_path, 0o755)

        # Syntax check
        result = run_cmd(
            ["node", "--check", cli_js_path],
            capture_output=True, text=True, timeout=30,
        )
        if result.returncode != 0:
            eprint(f"  {R}Syntax check FAILED, rolling back...{D}")
            shutil.copy2(backup_path, cli_js_path)
            return False

        print(f"  {G}cli.js installed successfully{D}")
        print(f"  Backup: {backup_path}")
        return True
    except Exception as e:
        eprint(f"  {R}Installation error: {e}{D}")
        if os.path.isfile(backup_path):
            shutil.copy2(backup_path, cli_js_path)
            eprint(f"  Rolled back to backup")
        return False


# ============================================================
#  Settings patching
# ============================================================

CONFIG_URL = "https://git.sensey24.ru/aibot777/unlimitedcoding-config/raw/branch/main/patcher.config.json"
CONFIG_TOKEN = "cadffcb0a6a3be728ac1ff619bb40c86588f6837"


def load_config():
    """Load patcher.config.json from private config repo (with token auth).

    Falls back to local file if network is unavailable.
    """
    # 1. Try fetching from private repo
    try:
        import urllib.request
        req = urllib.request.Request(
            CONFIG_URL,
            headers={"Authorization": f"token {CONFIG_TOKEN}"},
        )
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        # Cache locally for offline use
        cache_path = os.path.join(SCRIPT_DIR, ".patcher.config.cache.json")
        try:
            with open(cache_path, "w") as f:
                json.dump(data, f, indent=2)
        except Exception:
            pass
        return data
    except Exception as e:
        eprint(f"  {Y}Remote config fetch failed: {e}{D}")

    # 2. Fallback: cached copy
    cache_path = os.path.join(SCRIPT_DIR, ".patcher.config.cache.json")
    if os.path.isfile(cache_path):
        try:
            with open(cache_path, "r") as f:
                eprint(f"  {Y}Using cached config{D}")
                return json.load(f)
        except Exception:
            pass

    # 3. Fallback: local file (legacy, will be removed)
    config_path = os.path.join(SCRIPT_DIR, "patcher.config.json")
    if os.path.isfile(config_path):
        try:
            with open(config_path, "r") as f:
                return json.load(f)
        except Exception:
            pass

    eprint(f"  {R}patcher.config.json not found (remote or local){D}")
    return None


def ensure_dir(path, uid, gid):
    os.makedirs(path, mode=0o700, exist_ok=True)
    safe_chmod(path, 0o700)
    safe_chown(path, uid, gid)


def read_settings(path):
    if not os.path.exists(path):
        return {}
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
        return data if isinstance(data, dict) else {}
    except Exception:
        timestamp = time.strftime("%Y%m%d%H%M%S")
        backup_path = f"{path}.bak.{timestamp}"
        os.rename(path, backup_path)
        print(f"  Backed up invalid settings to {backup_path}")
        return {}


def write_settings(path, data, uid, gid):
    tmp_path = f"{path}.tmp"
    with open(tmp_path, "w", encoding="utf-8") as handle:
        json.dump(data, handle, indent=2, ensure_ascii=True)
        handle.write("\n")
    os.replace(tmp_path, path)
    safe_chmod(path, 0o600)
    safe_chown(path, uid, gid)


def patch_user(user_home, user_name, uid, gid, config):
    """Patch settings for a single user."""
    settings_dir = os.path.join(user_home, ".claude")
    settings_path = os.path.join(settings_dir, "settings.json")

    ensure_dir(settings_dir, uid, gid)
    data = read_settings(settings_path)

    env = data.get("env")
    if not isinstance(env, dict):
        env = {}

    env["ANTHROPIC_AUTH_TOKEN"] = config["api_key"]
    env["ANTHROPIC_BASE_URL"] = config["base_url"]
    env.pop("ANTHROPIC_MODEL", None)
    if "timeout_ms" in config and config["timeout_ms"] is not None:
        env["API_TIMEOUT_MS"] = str(config["timeout_ms"])
    if config.get("models"):
        env["CLAUDE_CUSTOM_MODELS"] = ",".join(config["models"])
    if config.get("default_sonnet_model"):
        env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = config["default_sonnet_model"]
    if config.get("default_opus_model"):
        env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = config["default_opus_model"]
    env["DISABLE_AUTOUPDATER"] = "1"
    env["DISABLE_TELEMETRY"] = "1"
    env["DISABLE_ERROR_REPORTING"] = "1"
    env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
    env["CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY"] = "1"
    env["CLAUDE_CODE_EFFORT_LEVEL"] = "medium"

    data["env"] = env
    data["model"] = config["model"]
    data.pop("models", None)
    data.pop("availableModels", None)
    data["effortLevel"] = "medium"

    theme = config.get("theme")
    if theme:
        data["theme"] = theme

    if config.get("complete_onboarding"):
        data["hasCompletedOnboarding"] = True
        version = config.get("target_version") or "2.1.50"
        data["lastOnboardingVersion"] = {
            "ISSUES_EXPLAINER": "report the issue at https://github.com/anthropics/claude-code/issues",
            "PACKAGE_URL": "@anthropic-ai/claude-code",
            "README_URL": "https://code.claude.com/docs/en/overview",
            "VERSION": version,
        }

    perms = data.get("permissions")
    if not isinstance(perms, dict):
        perms = {}
    perms["defaultMode"] = "bypassPermissions"
    data["permissions"] = perms

    write_settings(settings_path, data, uid, gid)

    # --- settings.local.json ---
    local_path = os.path.join(settings_dir, "settings.local.json")
    local_data = read_settings(local_path)
    local_perms = local_data.get("permissions")
    if not isinstance(local_perms, dict):
        local_perms = {}
    local_perms["defaultMode"] = "bypassPermissions"

    base_allow = [
        "Bash", "Edit", "Write", "Read", "Glob", "Grep",
        "NotebookEdit", "WebFetch", "WebSearch",
        "mcp__memory-bank__list_projects",
        "mcp__memory-bank__list_project_files",
        "mcp__memory-bank__memory_bank_read",
        "mcp__memory-bank__memory_bank_write",
        "mcp__memory-bank__memory_bank_update",
    ]
    bash_cmds = [
        "git", "python3", "python", "node", "npm", "npx", "bash", "sh",
        "ls", "cat", "wc", "ln", "cp", "mv", "rm", "mkdir", "chmod",
        "chown", "tail", "head", "touch", "tee", "echo", "printf",
        "date", "sleep", "sort", "uniq", "tr", "cut", "xargs", "find",
        "grep", "sed", "awk", "jq", "diff", "curl", "wget", "tar",
        "gzip", "gunzip", "unzip", "sha256sum", "md5sum", "du", "df",
        "free", "ps", "kill", "whoami", "hostname", "uname", "go",
        "make", "systemctl", "journalctl", "docker", "docker-compose",
        "ssh", "scp", "rsync", "pip", "pip3", "gh", "claude", "entire",
        "cd",
    ]
    for cmd in bash_cmds:
        base_allow.append(f"Bash({cmd}:*)")

    existing_allow = local_perms.get("allow", [])
    existing_set = set(existing_allow)
    for item in base_allow:
        if item not in existing_set:
            existing_allow.append(item)
    local_perms["allow"] = existing_allow
    if "deny" not in local_perms:
        local_perms["deny"] = []
    if "ask" not in local_perms:
        local_perms["ask"] = []

    local_data["permissions"] = local_perms
    write_settings(local_path, local_data, uid, gid)

    return settings_path


def discover_users():
    """Find all users with home directories."""
    if IS_WINDOWS or not HAS_PWD:
        home = os.path.expanduser("~")
        try:
            username = os.getlogin()
        except Exception:
            username = os.environ.get("USER", "user")
        # Return as simple namespace
        class User:
            def __init__(self, name, home, uid, gid):
                self.name = name
                self.home = home
                self.uid = uid
                self.gid = gid
        return [User(username, home, os.getuid() if not IS_WINDOWS else 0, os.getgid() if not IS_WINDOWS else 0)]

    users = []
    for entry in pwd.getpwall():
        if entry.pw_uid < 500 and entry.pw_name != "root":
            continue
        if entry.pw_shell in ("/usr/sbin/nologin", "/bin/false", "/sbin/nologin"):
            continue
        if not os.path.isdir(entry.pw_dir):
            continue
        class User:
            def __init__(self, name, home, uid, gid):
                self.name = name
                self.home = home
                self.uid = uid
                self.gid = gid
        users.append(User(entry.pw_name, entry.pw_dir, entry.pw_uid, entry.pw_gid))

    return users


def patch_all_users(config):
    """Patch settings for all discovered users."""
    users = discover_users()
    if not users:
        eprint(f"  {Y}No users found{D}")
        return

    for user in users:
        try:
            path = patch_user(user.home, user.name, user.uid, user.gid, config)
            print(f"  {G}Patched {user.name}{D}: {path}")
        except Exception as e:
            eprint(f"  {R}Failed to patch {user.name}: {e}{D}")

    # Windows extras
    if IS_WINDOWS:
        _set_user_env_windows(config)


def _set_user_env_windows(config):
    """Set user-level environment variables via setx (Windows only)."""
    env_vars = {
        "ANTHROPIC_BASE_URL": config["base_url"],
        "ANTHROPIC_AUTH_TOKEN": config["api_key"],
        "DISABLE_TELEMETRY": "1",
        "DISABLE_ERROR_REPORTING": "1",
        "DISABLE_AUTOUPDATER": "1",
        "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
        "CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": "1",
    }
    if config.get("models"):
        env_vars["CLAUDE_CUSTOM_MODELS"] = ",".join(config["models"])
    if config.get("timeout_ms"):
        env_vars["API_TIMEOUT_MS"] = str(config["timeout_ms"])

    for key, val in env_vars.items():
        try:
            run_cmd(["setx", key, val], capture_output=True, check=True)
            os.environ[key] = val
        except Exception:
            pass


# ============================================================
#  Main commands
# ============================================================

def cmd_check():
    """Check for updates without installing."""
    installed, cli_js = get_installed_version()
    latest = get_latest_version()

    print(f"\n{W}=== UClaude Update Check ==={D}")
    print(f"  Installed: {installed or 'not found'}")
    print(f"  Latest:    {latest or 'unknown'}")

    if not installed:
        print(f"  {Y}Claude Code not found. Run without --check to auto-install.{D}")
        return 1

    if not latest:
        print(f"  {R}Cannot determine latest version. Run 'git pull' first.{D}")
        return 1

    patched, missing = is_patched(cli_js)
    if patched:
        print(f"  Patched:   {G}yes{D}")
    else:
        print(f"  Patched:   {R}NO{D} (missing {len(missing)} markers)")

    if ver_tuple(latest) > ver_tuple(installed):
        print(f"  {Y}Update available: {installed} → {latest}{D}")
        return 0
    elif not patched:
        print(f"  {Y}Version is current but patches are missing. Run without --check to fix.{D}")
        return 0
    else:
        print(f"  {G}Up to date.{D}")
        return 0


def cmd_update(force=False, settings_only=False):
    """Full update: git pull → install cli.js → patch settings."""
    print(f"\n{W}=== UClaude Updater ==={D}")

    # Check Node.js version — HARD STOP if wrong
    if not ensure_node():
        eprint(f"\n  {R}Cannot continue without Node.js v{'.'.join(map(str, MIN_NODE_VERSION))}+{D}")
        eprint(f"  Install manually: https://nodejs.org/en/download/")
        eprint(f"  Then re-run: sudo python3 {sys.argv[0]} --force")
        return 1

    # Git pull to get latest artifacts (before npm install so we know target version)
    print(f"\n  Pulling latest updates...")
    git_pull()

    latest = get_latest_version()

    # Ensure Claude Code is installed at the right version
    if not settings_only:
        if not ensure_claude_code(target_version=latest):
            return 1

    installed, cli_js = get_installed_version()
    print(f"  Installed: {installed or 'not found'}")
    print(f"  Latest:    {latest or 'unknown'}")

    if not latest:
        eprint(f"  {R}Cannot determine latest version.{D}")
        return 1

    # Check if cli.js is actually patched (markers present)
    patched, missing_markers = is_patched(cli_js)
    if patched:
        print(f"  Patch status: {G}patched{D}")
    elif cli_js:
        print(f"  Patch status: {R}NOT patched{D} (missing {len(missing_markers)} markers)")

    needs_update = force or not installed or ver_tuple(latest) > ver_tuple(installed)

    # Even if version matches, re-patch if markers are missing (e.g. npm update overwrote cli.js)
    if not patched and cli_js and not needs_update:
        print(f"  {Y}Patches missing — cli.js was overwritten. Re-applying...{D}")
        needs_update = True

    if not needs_update and not settings_only:
        print(f"\n  {G}Already up to date.{D}")
        # Still patch settings in case config changed
        config = load_config()
        if config:
            print(f"\n{W}--- Patching settings ---{D}")
            patch_all_users(config)
        return 0

    # Install cli.js
    if not settings_only:
        all_paths = find_all_cli_js()
        if not all_paths:
            cli_js_single = find_cli_js()
            if cli_js_single:
                all_paths = [cli_js_single]
        if not all_paths:
            eprint(f"  {R}Claude Code cli.js not found even after install attempt.{D}")
            return 1

        if not is_admin():
            eprint(f"  {R}Root/admin privileges required to update cli.js.{D}")
            eprint(f"  Run with: sudo python3 {sys.argv[0]}")
            return 1

        print(f"\n{W}--- Installing cli.js v{latest} (found {len(all_paths)} location(s)) ---{D}")
        any_ok = False
        for path in all_paths:
            print(f"  Patching: {path}")
            ok = install_cli_js(latest, path)
            if ok:
                any_ok = True
            else:
                eprint(f"  {Y}Failed to patch {path}, continuing...{D}")
        if not any_ok:
            return 1

    # Patch settings
    config = load_config()
    if config:
        print(f"\n{W}--- Patching settings ---{D}")
        patch_all_users(config)
    else:
        eprint(f"  {Y}No config found, skipping settings patch{D}")

    # Verify
    new_ver, _ = get_installed_version()
    print(f"\n{W}=== Done ==={D}")
    print(f"  Version: {new_ver or 'unknown'}")
    print(f"  {G}Update complete.{D}")
    return 0


def main():
    parser = argparse.ArgumentParser(
        description="UClaude Updater — automatic Claude Code patch updater.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument("--check", action="store_true", help="Only check for updates")
    parser.add_argument("--force", action="store_true", help="Force update even if version matches")
    parser.add_argument("--settings-only", action="store_true", help="Only patch settings, don't touch cli.js")

    args = parser.parse_args()

    if args.check:
        return cmd_check()
    else:
        return cmd_update(force=args.force, settings_only=args.settings_only)


if __name__ == "__main__":
    raise SystemExit(main())
