"""openviking-server init - interactive setup wizard for OpenViking.

Guides users through model selection and configuration, with a focus on
local deployment via Ollama or llama.cpp for macOS / Apple Silicon beginners.
"""

from __future__ import annotations

import importlib
import json
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from openviking_cli.utils.config.consts import DEFAULT_CONFIG_DIR, OPENVIKING_CONFIG_ENV
from openviking_cli.utils.ollama import (
    check_ollama_running,
    get_ollama_models,
    install_ollama,
    is_model_available,
    is_ollama_installed,
    ollama_pull_model,
    start_ollama,
)

_DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
_DEFAULT_KIMI_BASE_URL = "https://api.kimi.com/coding"
_DEFAULT_GLM_BASE_URL = "https://api.z.ai/api/coding/paas/v4"
_DEFAULT_CODEX_MODEL = "gpt-5.4"
_DEFAULT_KIMI_MODEL = "kimi-code"
_DEFAULT_GLM_MODEL = "glm-4.6v"

# ---------------------------------------------------------------------------
# ANSI helpers (same pattern as doctor.py)
# ---------------------------------------------------------------------------

_USE_COLOR = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()


def _green(t: str) -> str:
    return f"\033[32m{t}\033[0m" if _USE_COLOR else t


def _red(t: str) -> str:
    return f"\033[31m{t}\033[0m" if _USE_COLOR else t


def _yellow(t: str) -> str:
    return f"\033[33m{t}\033[0m" if _USE_COLOR else t


def _dim(t: str) -> str:
    return f"\033[2m{t}\033[0m" if _USE_COLOR else t


def _bold(t: str) -> str:
    return f"\033[1m{t}\033[0m" if _USE_COLOR else t


def _cyan(t: str) -> str:
    return f"\033[36m{t}\033[0m" if _USE_COLOR else t


# ---------------------------------------------------------------------------
# Interactive prompt helpers (stdlib only)
# ---------------------------------------------------------------------------


def _prompt_choice(prompt: str, options: list[tuple[str, str]], default: int = 1) -> int:
    """Display numbered options and return 1-based selection index."""
    print(f"\n  {_bold(prompt)}\n")
    for i, (label, desc) in enumerate(options, 1):
        marker = "  "
        line = f"  {marker}[{i}] {label}"
        if desc:
            line += f"  {_dim(desc)}"
        print(line)

    while True:
        try:
            raw = input(f"\n  Select [{default}]: ").strip()
        except EOFError:
            return default
        if not raw:
            return default
        try:
            choice = int(raw)
            if 1 <= choice <= len(options):
                return choice
        except ValueError:
            pass
        print(f"  {_red('Please enter a number between 1 and ' + str(len(options)))}")


def _mask_secret(value: str, prefix: int = 7, suffix: int = 4) -> str:
    """Mask a secret string, showing only the first ``prefix`` and last ``suffix`` chars."""
    if not value:
        return ""
    if len(value) <= prefix + suffix:
        return "*" * len(value)
    return f"{value[:prefix]}{'*' * (len(value) - prefix - suffix)}{value[-suffix:]}"


def _masked_input(prompt: str) -> str:
    """Read a line of input, echoing ``*`` per character; on submit, rewrite
    the line to show ``prompt + _mask_secret(value)`` (prefix 7 + suffix 4).

    Falls back to plain ``input()`` when stdin/stdout aren't TTYs (tests,
    pipes) and to ``getpass.getpass`` (no echo at all) on platforms
    without ``termios`` (Windows).
    """
    import sys

    if not (sys.stdin.isatty() and sys.stdout.isatty()):
        return input(prompt)

    try:
        import termios
        import tty
    except ImportError:
        import getpass

        return getpass.getpass(prompt)

    sys.stdout.write(prompt)
    sys.stdout.flush()
    fd = sys.stdin.fileno()
    old_attrs = termios.tcgetattr(fd)
    chars: list[str] = []
    try:
        tty.setraw(fd)
        while True:
            ch = sys.stdin.read(1)
            if ch in ("\r", "\n"):
                break
            if ch == "\x03":  # Ctrl-C
                raise KeyboardInterrupt
            if ch == "\x04":  # Ctrl-D / EOF
                if not chars:
                    raise EOFError
                break
            if ch in ("\x7f", "\b"):  # Backspace / DEL
                if chars:
                    chars.pop()
                    sys.stdout.write("\b \b")
                    sys.stdout.flush()
                continue
            if ch < " ":  # Other control chars — ignore
                continue
            chars.append(ch)
            sys.stdout.write("*")
            sys.stdout.flush()
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
        value = "".join(chars)
        # Rewrite the line: \r → clear → prompt + masked preview + \n.
        sys.stdout.write("\r\033[2K" + prompt + _mask_secret(value) + "\n")
        sys.stdout.flush()
    return value


def _prompt_required_input(prompt: str, default: str | None = None, *, mask: bool = False) -> str:
    """Prompt for a required free-text value. When ``mask`` is True, echo ``*`` per char."""
    reader = _masked_input if mask else input
    while True:
        try:
            prompt_text = f"  {prompt} [{default}]: " if default is not None else f"  {prompt}: "
            raw = reader(prompt_text).strip()
        except EOFError:
            return default or ""
        if not raw and default is not None:
            return default
        if raw:
            return raw
        print(f"  {_red(prompt + ' is required')}")


def _prompt_api_key(prompt: str = "API Key") -> str:
    """Prompt for an API key with inline masked echo (no extra confirmation line)."""
    return _prompt_required_input(prompt, mask=True)


def _prompt_required_int(prompt: str, default: int | None = None) -> int | None:
    """Prompt for a required integer value."""
    while True:
        try:
            prompt_text = f"  {prompt} [{default}]: " if default is not None else f"  {prompt}: "
            raw = input(prompt_text).strip()
        except EOFError:
            return default
        if not raw:
            if default is not None:
                return default
            print(f"  {_red(prompt + ' is required')}")
            continue
        try:
            return int(raw)
        except ValueError:
            print(f"  {_red('Please enter a valid integer')}")


def _prompt_confirm(prompt: str, default: bool = True) -> bool:
    """Yes/no confirmation prompt."""
    hint = "Y/n" if default else "y/N"
    try:
        raw = input(f"  {prompt} [{hint}]: ").strip().lower()
    except EOFError:
        return default
    if not raw:
        return default
    return raw in ("y", "yes")


def _configured_hint(enabled: bool) -> str:
    """Return a non-sensitive summary label for setup output."""
    return "configured" if enabled else _dim("(not configured)")


# ---------------------------------------------------------------------------
# System info
# ---------------------------------------------------------------------------


def _get_system_ram_gb() -> int:
    """Get total system RAM in GB."""
    try:
        pages = os.sysconf("SC_PHYS_PAGES")
        page_size = os.sysconf("SC_PAGE_SIZE")
        return (pages * page_size) // (1024**3)
    except (ValueError, OSError, AttributeError):
        pass
    # Windows fallback
    try:
        import ctypes

        kernel32 = ctypes.windll.kernel32  # type: ignore[attr-defined]

        class MEMORYSTATUSEX(ctypes.Structure):
            _fields_ = [
                ("dwLength", ctypes.c_ulong),
                ("dwMemoryLoad", ctypes.c_ulong),
                ("ullTotalPhys", ctypes.c_ulonglong),
                ("ullAvailPhys", ctypes.c_ulonglong),
                ("ullTotalPageFile", ctypes.c_ulonglong),
                ("ullAvailPageFile", ctypes.c_ulonglong),
                ("ullTotalVirtual", ctypes.c_ulonglong),
                ("ullAvailVirtual", ctypes.c_ulonglong),
                ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
            ]

        stat = MEMORYSTATUSEX()
        stat.dwLength = ctypes.sizeof(stat)
        kernel32.GlobalMemoryStatusEx(ctypes.byref(stat))
        return stat.ullTotalPhys // (1024**3)
    except Exception:
        return 0


# ---------------------------------------------------------------------------
# Ollama interaction (delegates to openviking_cli.utils.ollama)
# ---------------------------------------------------------------------------


def _ensure_ollama() -> bool:
    """Make sure Ollama is installed and running (interactive). Returns True if ready."""
    print("\n  Checking Ollama...", end=" ", flush=True)

    if is_ollama_installed():
        if check_ollama_running():
            print(_green("running at localhost:11434"))
            return True
        print(_yellow("installed but not running"))
        print(f"  {_dim('Starting Ollama...')}", end=" ", flush=True)
        result = start_ollama()
        if result.success:
            print(_green("ready"))
        else:
            msg = result.stderr_output or result.message
            print(_yellow(f"failed ({msg})"))
        return result.success

    # Not installed
    print(_yellow("not installed"))
    if not _prompt_confirm("Install Ollama now?"):
        print(f"\n  {_dim('Manual install: https://ollama.com/download')}")
        return False

    print()
    if not install_ollama():
        print(f"  {_red('Installation failed.')}")
        print(f"  {_dim('Try manually: https://ollama.com/download')}")
        return False

    print(f"  {_green('OK')} Ollama installed")
    print(f"  {_dim('Starting Ollama...')}", end=" ", flush=True)
    result = start_ollama()
    if result.success:
        print(_green("ready"))
    else:
        msg = result.stderr_output or result.message
        print(_yellow(f"failed ({msg})"))
    return result.success


def _ensure_codex_auth() -> bool:
    import importlib

    importlib.import_module("openviking.models.vlm")
    codex_auth = importlib.import_module("openviking.models.vlm.backends.codex_auth")

    print("\n  Checking Codex OAuth...", end=" ", flush=True)
    try:
        creds = codex_auth.resolve_codex_runtime_credentials(refresh_if_expiring=False)
        source = creds.get("source", "unknown")
        print(_green(f"ready via {source}"))
        return True
    except Exception:
        print(_yellow("not ready"))

    status = codex_auth.get_codex_auth_status()
    bootstrap_path = status.get("bootstrap_path")

    if status.get("bootstrap_available") and bootstrap_path:
        if _prompt_confirm(f"Import existing Codex CLI auth from {bootstrap_path}?"):
            try:
                path = codex_auth.bootstrap_codex_auth()
            except codex_auth.CodexAuthError as exc:
                print(f"  {_yellow(str(exc))}")
            else:
                if path is not None:
                    print(f"  {_green('OK')} Imported Codex OAuth into {path}")
                    return True

    if _prompt_confirm("Sign in to Codex now?"):
        try:
            path = codex_auth.login_codex_with_device_code()
        except codex_auth.CodexAuthError as exc:
            print(f"  {_yellow(str(exc))}")
        else:
            print(f"  {_green('OK')} Codex OAuth stored in {path}")
            return True

    print(
        f"  {_dim('You can finish setup now and re-run `openviking-server init` later to complete Codex sign-in.')}"
    )
    return False


# ---------------------------------------------------------------------------
# Model presets
# ---------------------------------------------------------------------------


@dataclass
class EmbeddingPreset:
    label: str
    model: str  # Ollama model name
    dimension: int
    size_hint: str
    min_ram_gb: int  # Minimum recommended RAM


@dataclass
class VLMPreset:
    label: str
    ollama_model: str  # For ollama pull
    litellm_model: str  # For config: "ollama/xxx"
    size_hint: str
    min_ram_gb: int  # Minimum recommended RAM


EMBEDDING_PRESETS: list[EmbeddingPreset] = [
    EmbeddingPreset("Qwen3-Embedding 0.6B", "qwen3-embedding:0.6b", 1024, "~639 MB", 4),
    EmbeddingPreset("Qwen3-Embedding 4B", "qwen3-embedding:4b", 1024, "~2.5 GB", 8),
    EmbeddingPreset("Qwen3-Embedding 8B", "qwen3-embedding:8b", 1024, "~4.7 GB", 16),
    EmbeddingPreset("EmbeddingGemma 300M", "embeddinggemma:300m", 768, "~622 MB", 4),
]

VLM_PRESETS: list[VLMPreset] = [
    VLMPreset("Qwen 3.5 2B", "qwen3.5:2b", "ollama/qwen3.5:2b", "~2.7 GB", 4),
    VLMPreset("Qwen 3.5 4B", "qwen3.5:4b", "ollama/qwen3.5:4b", "~3.4 GB", 8),
    VLMPreset("Qwen 3.5 9B", "qwen3.5:9b", "ollama/qwen3.5:9b", "~6.6 GB", 16),
    VLMPreset("Qwen 3.5 27B", "qwen3.5:27b", "ollama/qwen3.5:27b", "~17 GB", 32),
    VLMPreset("Qwen 3.5 35B", "qwen3.5:35b", "ollama/qwen3.5:35b", "~24 GB", 48),
    VLMPreset("Qwen 3.5 122B", "qwen3.5:122b", "ollama/qwen3.5:122b", "~81 GB", 128),
    VLMPreset("Gemma 4 E2B", "gemma4:e2b", "ollama/gemma4:e2b", "~7.2 GB", 16),
    VLMPreset("Gemma 4 E4B", "gemma4:e4b", "ollama/gemma4:e4b", "~9.6 GB", 16),
    VLMPreset("Gemma 4 26B", "gemma4:26b", "ollama/gemma4:26b", "~18 GB", 32),
    VLMPreset("Gemma 4 31B", "gemma4:31b", "ollama/gemma4:31b", "~20 GB", 48),
]

# Recommended defaults indexed by RAM tier
_RAM_TIERS: list[tuple[int, int, int]] = [
    # (max_ram_gb, embedding_preset_index, vlm_preset_index)
    (8, 0, 0),  # ≤8 GB: qwen3-embedding:0.6b + qwen3.5:2b
    (16, 0, 1),  # 8-16 GB: qwen3-embedding:0.6b + qwen3.5:4b
    (32, 2, 2),  # 16-32 GB: qwen3-embedding:8b + qwen3.5:9b
    (64, 2, 7),  # 32-64 GB: qwen3-embedding:8b + gemma4:e4b
]
_RAM_DEFAULT_EMBED = 2  # ≥64 GB: qwen3-embedding:8b
_RAM_DEFAULT_VLM = 3  # ≥64 GB: qwen3.5:27b


def _get_recommended_indices(ram_gb: int) -> tuple[int, int]:
    """Return (embedding_index, vlm_index) for the RAM tier (0-based)."""
    for max_ram, emb_idx, vlm_idx in _RAM_TIERS:
        if ram_gb <= max_ram:
            return emb_idx, vlm_idx
    return _RAM_DEFAULT_EMBED, _RAM_DEFAULT_VLM


# ---------------------------------------------------------------------------
# Cloud provider presets
# ---------------------------------------------------------------------------


@dataclass
class CloudProvider:
    label: str
    provider: str
    default_api_base: str
    default_embedding_model: str
    default_embedding_dim: int
    default_vlm_model: str


CLOUD_PROVIDERS: list[CloudProvider] = [
    CloudProvider(
        "VolcEngine (火山引擎)",
        "volcengine",
        "https://ark.cn-beijing.volces.com/api/v3",
        "doubao-embedding-vision-251215",
        1024,
        "doubao-seed-2-0-code-preview-260215",
    ),
    CloudProvider(
        "BytePlus",
        "volcengine",
        "https://ark.ap-southeast.bytepluses.com/api/v3",
        "skylark-embedding-vision-251215",
        1024,
        "doubao-seed-2-0-code-preview-260215",
    ),
    CloudProvider(
        "OpenAI",
        "openai",
        "https://api.openai.com/v1",
        "text-embedding-3-small",
        1536,
        "gpt-5.4",
    ),
]


def _get_cloud_provider_by_label(label: str) -> CloudProvider:
    for provider in CLOUD_PROVIDERS:
        if provider.label == label:
            return provider
    raise ValueError(f"Unknown cloud provider: {label}")


_WIZARD_VLM_OPTIONS: list[tuple[str, str]] = [
    ("VolcEngine (火山引擎)", "(API)"),
    ("BytePlus", "(API)"),
    ("OpenAI", "(API)"),
    ("OpenAI Codex", "(Subscription)"),
    ("Kimi", "(Subscription API Key)"),
    ("GLM", "(Subscription API Key)"),
    ("Custom (OpenAI-compatible)", "(Any OpenAI-compatible endpoint)"),
]


# ---------------------------------------------------------------------------
# llama.cpp local embedding presets
# ---------------------------------------------------------------------------


@dataclass
class LocalGGUFPreset:
    label: str
    model_name: str  # key in LOCAL_DENSE_MODEL_SPECS
    dimension: int
    size_hint: str


LOCAL_GGUF_PRESETS: list[LocalGGUFPreset] = [
    LocalGGUFPreset("BGE-small-zh v1.5 (f16)", "bge-small-zh-v1.5-f16", 512, "~24 MB"),
]


def _is_llamacpp_installed() -> bool:
    try:
        importlib.import_module("llama_cpp")
        return True
    except ImportError:
        return False


def _install_llamacpp() -> bool:
    """Attempt to install llama-cpp-python via pip.

    On the first attempt, uses the default build flags.  If compilation
    fails (common on ARM with older binutils that reject advanced
    ``-march`` extensions), retries with ``GGML_NATIVE=OFF`` to produce
    a generic build.
    """
    pip_cmd = [sys.executable, "-m", "pip", "install", "openviking[local-embed]"]

    try:
        subprocess.run(pip_cmd, check=True)
        return True
    except (subprocess.CalledProcessError, FileNotFoundError):
        pass

    print(f"  {_yellow('Native build failed, retrying with generic CPU flags...')}")
    env = os.environ.copy()
    prev = env.get("CMAKE_ARGS", "")
    env["CMAKE_ARGS"] = f"{prev} -DGGML_NATIVE=OFF -DLLAMA_NATIVE=OFF".strip()
    try:
        subprocess.run(pip_cmd, check=True, env=env)
        return True
    except (subprocess.CalledProcessError, FileNotFoundError):
        return False


def _check_gguf_model_cached(model_name: str, cache_dir: str | None = None) -> bool:
    from openviking.models.embedder.local_embedders import get_local_model_cache_path

    return get_local_model_cache_path(model_name, cache_dir).exists()


# ---------------------------------------------------------------------------
# Config building
# ---------------------------------------------------------------------------


def _build_ollama_config(
    embedding: EmbeddingPreset,
    vlm: VLMPreset,
    workspace: str,
) -> dict[str, Any]:
    """Build ov.conf dict for Ollama-based setup."""
    return {
        "storage": {"workspace": workspace},
        "embedding": {
            "dense": {
                "provider": "ollama",
                "model": embedding.model,
                "api_base": "http://localhost:11434/v1",
                "dimension": embedding.dimension,
                "input": "text",
            },
        },
        "vlm": {
            "provider": "litellm",
            "model": vlm.litellm_model,
            "api_key": "no-key",
            "api_base": "http://localhost:11434",
            "temperature": 0.0,
            "max_retries": 2,
        },
    }


def _build_local_config(
    model_name: str,
    dimension: int,
    workspace: str,
    model_path: str | None = None,
    cache_dir: str | None = None,
    vlm_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Build ov.conf dict for llama.cpp local embedding setup."""
    embedding_dense: dict[str, Any] = {
        "provider": "local",
        "model": model_name,
        "dimension": dimension,
    }
    if model_path:
        embedding_dense["model_path"] = model_path
    if cache_dir:
        embedding_dense["cache_dir"] = cache_dir

    config: dict[str, Any] = {
        "storage": {"workspace": workspace},
        "embedding": {"dense": embedding_dense},
    }
    if vlm_config:
        config["vlm"] = vlm_config
    return config


def _build_cloud_config(
    provider: CloudProvider,
    embedding_api_key: str,
    embedding_model: str,
    embedding_dim: int,
    vlm_model: str,
    workspace: str,
    embedding_api_base: str | None = None,
    vlm_provider: str | None = None,
    vlm_api_key: str | None = None,
    vlm_api_base: str | None = None,
) -> dict[str, Any]:
    resolved_vlm_provider = vlm_provider or provider.provider
    resolved_vlm_api_base = vlm_api_base or provider.default_api_base
    vlm_config: dict[str, Any] = {
        "provider": resolved_vlm_provider,
        "model": vlm_model,
        "api_base": resolved_vlm_api_base,
        "temperature": 0.0,
        "max_retries": 2,
    }
    if vlm_api_key:
        vlm_config["api_key"] = vlm_api_key

    return {
        "storage": {"workspace": workspace},
        "embedding": {
            "dense": {
                "provider": provider.provider,
                "model": embedding_model,
                "api_key": embedding_api_key,
                "api_base": embedding_api_base or provider.default_api_base,
                "dimension": embedding_dim,
            },
        },
        "vlm": vlm_config,
    }


# ---------------------------------------------------------------------------
# Config I/O
# ---------------------------------------------------------------------------

_PIP_LOCAL_EMBED = 'pip install "openviking[local-embed]"'


def _config_path() -> Path:
    """Where init writes ov.conf — honors OPENVIKING_CONFIG_FILE."""
    override = os.environ.get(OPENVIKING_CONFIG_ENV)
    if override:
        return Path(override).expanduser()
    return DEFAULT_CONFIG_DIR / "ov.conf"


def _workspace_path() -> str:
    """Workspace lives next to ov.conf so a single mount captures everything."""
    return str(_config_path().parent / "data")


def _next_backup_path(config_path: Path) -> Path:
    """Return a non-conflicting backup path: .bak, then .bak.1, .bak.2, ..."""
    base = config_path.with_suffix(".conf.bak")
    if not base.exists():
        return base
    i = 1
    while True:
        candidate = base.with_suffix(f".bak.{i}")
        if not candidate.exists():
            return candidate
        i += 1


def _write_config(config_dict: dict[str, Any], config_path: Path) -> bool:
    """Write config dict as JSON. Backs up existing file as .bak (rotates on conflict)."""
    try:
        config_path.parent.mkdir(parents=True, exist_ok=True)
        if config_path.exists():
            backup = _next_backup_path(config_path)
            config_path.rename(backup)
            print(f"  {_dim('Existing config backed up to ' + str(backup))}")
        config_path.write_text(
            json.dumps(config_dict, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
        )
        return True
    except OSError as exc:
        print(f"  {_red(f'Failed to write config: {exc}')}")
        return False


# ---------------------------------------------------------------------------
# Wizard flows
# ---------------------------------------------------------------------------


def _wizard_ollama() -> dict[str, Any] | None:
    """Ollama-based local model setup flow."""
    # Ensure Ollama is installed and running
    ollama_running = _ensure_ollama()

    if not ollama_running:
        if not _prompt_confirm(
            "Continue without Ollama? (config will be generated but models won't be pulled)",
            default=False,
        ):
            return None

    available_models = get_ollama_models() if ollama_running else []

    # System RAM
    ram_gb = _get_system_ram_gb()
    rec_embed_idx, rec_vlm_idx = _get_recommended_indices(ram_gb)
    if ram_gb > 0:
        print(f"\n  {_dim(f'Detected {ram_gb} GB RAM')}")

    # --- Embedding selection ---
    embed_options: list[tuple[str, str]] = []
    for i, p in enumerate(EMBEDDING_PRESETS):
        rec = " *" if i == rec_embed_idx else ""
        avail = ""
        if ollama_running and is_model_available(p.model, available_models):
            avail = _green(" [downloaded]")
        embed_options.append(
            (
                f"{p.label}",
                f"({p.dimension}d, {p.size_hint}){avail}{rec}",
            )
        )

    embed_choice = _prompt_choice("Embedding model:", embed_options, default=rec_embed_idx + 1)
    embedding = EMBEDDING_PRESETS[embed_choice - 1]

    # Pull embedding model
    if ollama_running and not is_model_available(embedding.model, available_models):
        if _prompt_confirm(f"'{embedding.model}' not found locally. Pull now?"):
            print()
            if not ollama_pull_model(embedding.model):
                print(
                    f"  {_yellow('Pull failed. You can pull it later: ollama pull ' + embedding.model)}"
                )
            else:
                print(f"  {_green('OK')} {embedding.model} pulled successfully")

    # --- VLM selection ---
    vlm_options: list[tuple[str, str]] = []
    for i, p in enumerate(VLM_PRESETS):
        rec = " *" if i == rec_vlm_idx else ""
        avail = ""
        if ollama_running and is_model_available(p.ollama_model, available_models):
            avail = _green(" [downloaded]")
        vlm_options.append(
            (
                f"{p.label}",
                f"({p.size_hint}){avail}{rec}",
            )
        )

    vlm_choice = _prompt_choice("Language model (VLM):", vlm_options, default=rec_vlm_idx + 1)
    vlm = VLM_PRESETS[vlm_choice - 1]

    # Pull VLM model
    if ollama_running and not is_model_available(vlm.ollama_model, available_models):
        if _prompt_confirm(f"'{vlm.ollama_model}' not found locally. Pull now?"):
            print()
            if not ollama_pull_model(vlm.ollama_model):
                print(
                    f"  {_yellow('Pull failed. You can pull it later: ollama pull ' + vlm.ollama_model)}"
                )
            else:
                print(f"  {_green('OK')} {vlm.ollama_model} pulled successfully")

    return _build_ollama_config(embedding, vlm, _workspace_path())


def _wizard_llamacpp() -> dict[str, Any] | None:
    """llama.cpp local embedding setup flow."""
    # --- Step 1: check / install llama-cpp-python ---
    print("\n  Checking llama-cpp-python...", end=" ", flush=True)

    if _is_llamacpp_installed():
        print(_green("installed"))
    else:
        print(_yellow("not installed"))
        print(f"\n  {_dim('llama-cpp-python is required for local CPU embedding.')}")
        if _prompt_confirm(f"Install now? ({_PIP_LOCAL_EMBED})"):
            print()
            if _install_llamacpp():
                print(f"  {_green('OK')} llama-cpp-python installed")
            else:
                print(f"  {_red('Installation failed.')}")
                print(f"  {_dim('Try manually: ' + _PIP_LOCAL_EMBED)}")
                if not _prompt_confirm(
                    "Continue anyway? (config will be generated)", default=False
                ):
                    return None
        else:
            print(f"\n  {_dim('Install later: ' + _PIP_LOCAL_EMBED)}")
            if not _prompt_confirm("Continue anyway? (config will be generated)", default=False):
                return None

    # --- Step 2: select embedding model ---
    model_options: list[tuple[str, str]] = []
    for p in LOCAL_GGUF_PRESETS:
        cached = ""
        try:
            if _check_gguf_model_cached(p.model_name):
                cached = _green(" [downloaded]")
        except Exception:
            pass
        model_options.append(
            (
                p.label,
                f"({p.dimension}d, {p.size_hint}){cached}",
            )
        )

    model_choice = _prompt_choice("Embedding model:", model_options, default=1)

    preset = LOCAL_GGUF_PRESETS[model_choice - 1]
    model_name = preset.model_name
    dimension = preset.dimension
    custom_model_path: str | None = None

    # Download if not cached
    try:
        if not _check_gguf_model_cached(model_name):
            if _prompt_confirm(
                f"Model '{model_name}' not downloaded yet. Download now? ({preset.size_hint})"
            ):
                print(f"\n  {_dim('Downloading...')}", end=" ", flush=True)
                try:
                    import requests

                    from openviking.models.embedder.local_embedders import (
                        get_local_model_cache_path,
                        get_local_model_spec,
                    )

                    spec = get_local_model_spec(model_name)
                    target = get_local_model_cache_path(model_name)
                    target.parent.mkdir(parents=True, exist_ok=True)
                    tmp = target.with_suffix(target.suffix + ".part")
                    with requests.get(spec.download_url, stream=True, timeout=(10, 300)) as resp:
                        resp.raise_for_status()
                        total = int(resp.headers.get("content-length", 0))
                        downloaded = 0
                        with tmp.open("wb") as fh:
                            for chunk in resp.iter_content(chunk_size=1024 * 1024):
                                if chunk:
                                    fh.write(chunk)
                                    downloaded += len(chunk)
                                    if total > 0:
                                        pct = downloaded * 100 // total
                                        print(
                                            f"\r  {_dim(f'Downloading... {pct}%')}",
                                            end=" ",
                                            flush=True,
                                        )
                    os.replace(tmp, target)
                    print(f"\r  {_green('OK')} Model downloaded to {target}         ")
                except Exception as exc:
                    print(f"\r  {_yellow(f'Download failed: {exc}')}")
                    print(f"  {_dim('Model will be auto-downloaded on first server start.')}")
            else:
                print(f"  {_dim('Model will be auto-downloaded on first server start.')}")
    except Exception:
        pass

    # --- Step 3: VLM selection ---
    print()
    vlm_mode = _prompt_choice(
        "VLM (language model) setup:",
        [
            ("Use Ollama for VLM", _dim("(requires Ollama installed)")),
            ("Use Cloud API for VLM", _dim("(VolcEngine, BytePlus, OpenAI, etc.)")),
            ("Skip VLM", _dim("(embedding only, add VLM later)")),
        ],
        default=1,
    )

    vlm_config: dict[str, Any] | None = None

    if vlm_mode == 1:
        # Ollama VLM
        ollama_running = _ensure_ollama()
        if not ollama_running:
            if not _prompt_confirm("Continue without Ollama?", default=False):
                return None

        available_models = get_ollama_models() if ollama_running else []
        ram_gb = _get_system_ram_gb()
        _, rec_vlm_idx = _get_recommended_indices(ram_gb)

        vlm_options: list[tuple[str, str]] = []
        for i, p in enumerate(VLM_PRESETS):
            rec = " *" if i == rec_vlm_idx else ""
            avail = ""
            if ollama_running and is_model_available(p.ollama_model, available_models):
                avail = _green(" [downloaded]")
            vlm_options.append((f"{p.label}", f"({p.size_hint}){avail}{rec}"))

        vlm_choice = _prompt_choice("Language model (VLM):", vlm_options, default=rec_vlm_idx + 1)
        vlm = VLM_PRESETS[vlm_choice - 1]

        if ollama_running and not is_model_available(vlm.ollama_model, available_models):
            if _prompt_confirm(f"'{vlm.ollama_model}' not found locally. Pull now?"):
                print()
                if not ollama_pull_model(vlm.ollama_model):
                    print(
                        f"  {_yellow('Pull failed. You can pull it later: ollama pull ' + vlm.ollama_model)}"
                    )
                else:
                    print(f"  {_green('OK')} {vlm.ollama_model} pulled successfully")

        vlm_config = {
            "provider": "litellm",
            "model": vlm.litellm_model,
            "api_key": "no-key",
            "api_base": "http://localhost:11434",
            "temperature": 0.0,
            "max_retries": 2,
        }

    elif vlm_mode == 2:
        # Cloud VLM
        provider_options = [(p.label, "") for p in CLOUD_PROVIDERS]
        provider_options.append(("Custom (OpenAI-compatible)", ""))
        choice = _prompt_choice("Cloud provider for VLM:", provider_options, default=1)

        if choice > len(CLOUD_PROVIDERS):
            print(f"\n  {_bold('Custom OpenAI-compatible VLM configuration')}")
            vlm_api_base = _prompt_required_input("API Base URL")
            vlm_api_key = _prompt_api_key("VLM API Key")
            vlm_model = _prompt_required_input("Vision Model")
            vlm_provider = "openai"
        else:
            provider = CLOUD_PROVIDERS[choice - 1]
            vlm_api_key = _prompt_api_key("VLM API Key")
            if not vlm_api_key:
                print(f"  {_red('API key is required')}")
                return None
            vlm_model = _prompt_required_input("Vision Model")
            vlm_api_base = provider.default_api_base
            vlm_provider = provider.provider

        vlm_config = {
            "provider": vlm_provider,
            "model": vlm_model,
            "api_base": vlm_api_base,
            "temperature": 0.0,
            "max_retries": 2,
        }
        if vlm_api_key:
            vlm_config["api_key"] = vlm_api_key

    return _build_local_config(
        model_name=model_name,
        dimension=dimension,
        workspace=_workspace_path(),
        model_path=custom_model_path,
        vlm_config=vlm_config,
    )


def _wizard_cloud() -> dict[str, Any] | None:
    """Cloud API model setup flow."""
    # Provider selection
    provider_options = [(p.label, "") for p in CLOUD_PROVIDERS]
    provider_options.append(("Other (manual)", ""))
    choice = _prompt_choice("Embedding provider:", provider_options, default=1)

    if choice > len(CLOUD_PROVIDERS):
        # Manual / Other
        print(f"\n  See example config: {_cyan('examples/ov.conf.example')}")
        print(f"  Edit {_cyan(str(_config_path()))} manually.\n")
        return None

    provider = CLOUD_PROVIDERS[choice - 1]
    workspace = _workspace_path()

    # Embedding config
    print(f"\n  {_bold('Embedding configuration')}")
    embedding_api_key = _prompt_api_key("API Key")
    if not embedding_api_key:
        print(f"  {_red('API key is required')}")
        return None
    embedding_model = _prompt_required_input("Model", default=provider.default_embedding_model)
    embedding_dim = _prompt_required_int("Dimension", default=provider.default_embedding_dim)
    if embedding_dim is None:
        print(f"  {_red('Dimension is required')}")
        return None
    embedding_api_base = provider.default_api_base

    vlm_mode = _prompt_choice("VLM provider:", _WIZARD_VLM_OPTIONS, default=1)

    if vlm_mode == 1:
        vlm_choice = _get_cloud_provider_by_label("VolcEngine (火山引擎)")
        print(f"\n  {_bold('VolcEngine VLM configuration')}")
        vlm_api_key = _prompt_api_key("API Key")
        if not vlm_api_key:
            print(f"  {_red('API key is required')}")
            return None
        vlm_model = _prompt_required_input("Model", default=vlm_choice.default_vlm_model)
        vlm_api_base = vlm_choice.default_api_base
        vlm_provider = vlm_choice.provider
    elif vlm_mode == 2:
        vlm_choice = _get_cloud_provider_by_label("BytePlus")
        print(f"\n  {_bold('BytePlus VLM configuration')}")
        vlm_api_key = _prompt_api_key("API Key")
        if not vlm_api_key:
            print(f"  {_red('API key is required')}")
            return None
        vlm_model = _prompt_required_input("Model", default=vlm_choice.default_vlm_model)
        vlm_api_base = vlm_choice.default_api_base
        vlm_provider = vlm_choice.provider
    elif vlm_mode == 3:
        vlm_choice = _get_cloud_provider_by_label("OpenAI")
        print(f"\n  {_bold('OpenAI VLM configuration')}")
        vlm_api_key = _prompt_api_key("API Key")
        if not vlm_api_key:
            print(f"  {_red('API key is required')}")
            return None
        vlm_model = _prompt_required_input("Model", default=vlm_choice.default_vlm_model)
        vlm_api_base = vlm_choice.default_api_base
        vlm_provider = vlm_choice.provider
    elif vlm_mode == 4:
        _ensure_codex_auth()
        print(f"\n  {_bold('Codex VLM configuration')}")
        vlm_model = _prompt_required_input("Model", default=_DEFAULT_CODEX_MODEL)
        vlm_api_base = _DEFAULT_CODEX_BASE_URL
        vlm_api_key = None
        vlm_provider = "openai-codex"
    elif vlm_mode == 5:
        print(f"\n  {_bold('Kimi VLM configuration')}")
        vlm_api_key = _prompt_api_key("API Key")
        if not vlm_api_key:
            print(f"  {_red('API key is required')}")
            return None
        vlm_model = _prompt_required_input("Model", default=_DEFAULT_KIMI_MODEL)
        vlm_api_base = _DEFAULT_KIMI_BASE_URL
        vlm_provider = "kimi"
    elif vlm_mode == 6:
        print(f"\n  {_bold('GLM VLM configuration')}")
        vlm_api_key = _prompt_api_key("API Key")
        if not vlm_api_key:
            print(f"  {_red('API key is required')}")
            return None
        vlm_model = _prompt_required_input("Model", default=_DEFAULT_GLM_MODEL)
        vlm_api_base = _DEFAULT_GLM_BASE_URL
        vlm_provider = "glm"
    else:
        print(f"\n  {_bold('Custom OpenAI-compatible VLM configuration')}")
        vlm_api_base = _prompt_required_input("API Base URL")
        vlm_api_key = _prompt_api_key("API Key")
        vlm_model = _prompt_required_input("Model")
        vlm_provider = "openai"

    return _build_cloud_config(
        provider,
        embedding_api_key,
        embedding_model,
        embedding_dim,
        vlm_model,
        workspace,
        embedding_api_base=embedding_api_base,
        vlm_provider=vlm_provider,
        vlm_api_key=vlm_api_key,
        vlm_api_base=vlm_api_base,
    )


def _wizard_server() -> dict[str, Any] | None:
    """Prompt for server host binding and root_api_key (when remote)."""
    print(f"\n  {_bold('Server binding')}")
    print(f"  {_dim('Local: only this machine can reach the server.')}")
    print(f"  {_dim('Remote: bind to 0.0.0.0 — required for Docker / LAN access.')}")

    mode = _prompt_choice(
        "Bind server host to:",
        [
            ("Local (127.0.0.1)", _dim("(default, safer)")),
            ("Remote (0.0.0.0)", _dim("(required for Docker / remote access)")),
        ],
        default=1,
    )

    if mode == 1:
        return {"host": "127.0.0.1"}

    print(f"\n  {_bold('Remote binding requires a root API key.')}")
    print(f"  {_dim('Clients must send this key as a Bearer token to authenticate.')}")
    root_api_key = _prompt_api_key("Root API Key")
    if not root_api_key:
        print(f"  {_red('Root API key is required for remote binding')}")
        return None
    return {"host": "0.0.0.0", "root_api_key": root_api_key}


def _wizard_custom() -> dict[str, Any] | None:
    """Custom configuration - point user to example config."""
    config_path = _config_path()
    example = Path(__file__).parent.parent / "examples" / "ov.conf.example"
    if example.exists():
        print(f"\n  Example config: {_cyan(str(example))}")
    print(f"  Config path:    {_cyan(str(config_path))}")

    editor = os.environ.get("EDITOR", os.environ.get("VISUAL", ""))
    if editor:
        if _prompt_confirm(f"Open {config_path} in {editor}?"):
            config_path.parent.mkdir(parents=True, exist_ok=True)
            if not config_path.exists():
                # Copy example as starting point
                try:
                    config_path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
                except OSError:
                    pass
            subprocess.run([editor, str(config_path)], check=False)
    else:
        print(f"\n  {_dim('Set $EDITOR to open the config file automatically.')}")
    return None


# ---------------------------------------------------------------------------
# Main orchestrator
# ---------------------------------------------------------------------------


def run_init() -> int:
    """Run the interactive setup wizard."""
    config_path = _config_path()
    workspace = _workspace_path()

    print(f"\n  {_bold('OpenViking Setup')}")
    print(f"  {'=' * 16}\n")
    print(f"  {_dim(f'Data will be stored under {workspace} unless you edit ov.conf later.')}\n")

    # Check for existing config
    if config_path.exists():
        print(f"  {_yellow('Existing config found:')} {config_path}")
        if not _prompt_confirm("Overwrite? (current config will be backed up as .bak)"):
            print("  Setup cancelled.\n")
            return 0

    # Deployment mode
    mode = _prompt_choice(
        "Choose setup mode:",
        [
            ("Cloud API", "(VolcEngine, BytePlus, OpenAI, etc.)"),
            ("Local embedding via llama.cpp", "(CPU embedding, no GPU required)"),
            ("Local models via Ollama", "(recommended for macOS / Apple Silicon)"),
            ("Custom", "(manual editing)"),
        ],
        default=1,
    )

    config_dict: dict[str, Any] | None = None

    if mode == 1:
        config_dict = _wizard_cloud()
    elif mode == 2:
        config_dict = _wizard_llamacpp()
    elif mode == 3:
        config_dict = _wizard_ollama()
    else:
        _wizard_custom()
        return 0

    if config_dict is None:
        print("\n  Setup cancelled.\n")
        return 0

    server_dict = _wizard_server()
    if server_dict is None:
        print("\n  Setup cancelled.\n")
        return 0
    config_dict["server"] = server_dict

    # Summary
    emb = config_dict.get("embedding", {}).get("dense", {})
    vlm = config_dict.get("vlm", {})

    print(f"\n  {_bold('Summary:')}")
    print(f"    Embedding:  {_configured_hint(bool(emb))}")
    if emb.get("model_path"):
        print("    Model path: custom local model (hidden)")
    vlm_summary = _configured_hint(bool(vlm))
    print(f"    VLM:        {vlm_summary}")
    print(f"    Server:     bound to {server_dict['host']}")
    if server_dict.get("root_api_key"):
        print("    Root API key: configured (hidden)")
    print("    Workspace:  configured (hidden)")
    print("    Config:     default config location")

    if not _prompt_confirm("\n  Save configuration?"):
        print("\n  Setup cancelled.\n")
        return 0

    # Write
    if not _write_config(config_dict, config_path):
        return 1

    print(f"  {_green('OK')} Configuration written to the default config location\n")

    # Post-init tips
    print(f"  {_bold('Next steps:')}")
    if emb.get("provider") == "local":
        print(f"    Install runtime:   {_cyan(_PIP_LOCAL_EMBED)}")
    print(f"    Start the server:  {_cyan('openviking-server')}")
    print(f"    Validate setup:    {_cyan('openviking-server doctor')}")
    print()

    return 0


def main() -> int:
    """Entry point for ``openviking-server init``."""
    try:
        return run_init()
    except KeyboardInterrupt:
        print("\n\n  Setup cancelled.\n")
        return 130
