# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""Self-hosted service wizards — Jellyfin, Forgejo/Gitea, HA, Joplin, Pi-hole, Nextcloud, Proton, Email Server."""

from __future__ import annotations

import asyncio
import logging
import os
from pathlib import Path

from ..formatting import fmt_bold, fmt_code, fmt_italic
from ._helpers import _check_tcp_port, _service_doc_blurb, _test_http_service, _update_env_file
from ._protocol import WizardHost

logger = logging.getLogger(__name__)


# ── Self-hosted: Jellyfin ────────────────────────────────────────


async def wizard_jellyfin(host: WizardHost, recipient_id: str, args: list):
    m = host.format_mode
    sub = args[0] if args else ""

    if sub == "test":
        await wizard_jellyfin_test(host, recipient_id)
        return

    if sub.startswith("http") and len(args) >= 3:
        await wizard_jellyfin_save(host, recipient_id, args[0], args[1], args[2])
        return

    has_url = os.environ.get("JELLYFIN_URL", "")
    has_token = bool(os.environ.get("JELLYFIN_TOKEN"))
    has_user_id = bool(os.environ.get("JELLYFIN_USER_ID"))

    if has_url and has_token and has_user_id:
        await host.wizard_send(
            recipient_id,
            f"\U0001f3ac {fmt_bold('Jellyfin Configuration', m)}\n\n"
            f"  \u2705 URL: {fmt_code(has_url, m)}\n"
            f"  \u2705 API Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n"
            f"  \u2705 User ID: {fmt_code(os.environ.get('JELLYFIN_USER_ID', '')[:8] + '...', m)}\n\n"
            f"{fmt_bold('Commands:', m)}\n"
            f"  {fmt_code('/connect jellyfin test', m)} \u2014 test connection\n",
        )
        return

    blurb = _service_doc_blurb("jellyfin", m)
    if host.supports_buttons:
        await start_jellyfin_wizard(host, recipient_id)
    else:
        await host.wizard_send(
            recipient_id,
            f"\U0001f3ac {fmt_bold('Connect Jellyfin', m)}\n"
            f"{blurb}\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Open Jellyfin Dashboard \u2192 API Keys \u2192 Create\n"
            f"2. Copy the API key\n"
            f"3. Go to Users \u2192 click your user \u2192 copy the GUID from the URL\n"
            f"   (the long hex string like {fmt_code('a1b2c3d4e5f6...', m)})\n\n"
            f"{fmt_bold('One-shot setup (3 values, space-separated):', m)}\n"
            f"  {fmt_code('/connect jellyfin <URL> <TOKEN> <USER_ID>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect jellyfin https://jellyfin.example.org abc123 a1b2c3d4...', m)}",
        )


async def start_jellyfin_wizard(host: WizardHost, recipient_id: str):
    host._ensure_wizard_state()
    m = host.format_mode
    host._wizard_state[recipient_id] = {
        "type": "jellyfin",
        "step": "url",
    }
    blurb = _service_doc_blurb("jellyfin", m)
    await host.wizard_send(
        recipient_id,
        f"\U0001f3ac {fmt_bold('Jellyfin Setup', m)}\n"
        f"{blurb}\n"
        f"This wizard will configure your Jellyfin media server.\n\n"
        f"Enter your {fmt_bold('Jellyfin server URL', m)}:\n\n"
        f"{fmt_italic('Example: https://jellyfin.example.org', m)}",
    )


async def jellyfin_handle_step(
    host: WizardHost,
    recipient_id: str,
    text: str,
    message_ref,
):
    host._ensure_wizard_state()
    m = host.format_mode
    state = host._wizard_state.get(recipient_id)
    if not state:
        return
    step = state["step"]
    text = text.strip()

    if step == "url":
        if not text.startswith(("http://", "https://")):
            await host.wizard_send(
                recipient_id,
                "That doesn't look like a URL. "
                "Please enter a URL starting with http:// or https://:",
            )
            return
        state["url"] = text.rstrip("/")
        state["step"] = "token"

        warning = ""
        if not host.supports_message_deletion:
            pw_warning = (
                "This channel cannot delete messages. "
                "Your token will remain visible in chat history. "
                "Consider using one-shot: /connect jellyfin <URL> <TOKEN> <USER_ID>"
            )
            warning = f"\n\n\u26a0\ufe0f {fmt_italic(pw_warning, m)}"

        await host.wizard_send(
            recipient_id,
            f"\U0001f510 Enter your {fmt_bold('Jellyfin API key', m)}:\n\n"
            f"Create one at: Dashboard \u2192 API Keys \u2192 Create"
            f"{warning}"
            + (
                "\n\n"
                + fmt_italic(
                    "Your message will be deleted immediately for safety.",
                    m,
                )
                if host.supports_message_deletion
                else ""
            ),
        )

    elif step == "token":
        if host.supports_message_deletion and message_ref is not None:
            try:
                await host.wizard_delete_message(recipient_id, message_ref)
            except Exception:
                pass

        state["token"] = text
        state["step"] = "user_id"
        await host.wizard_send(
            recipient_id,
            f"Enter your {fmt_bold('Jellyfin User ID', m)}:\n\n"
            f"Find it at: Users \u2192 click your user \u2192 copy the GUID from the URL\n"
            f"{fmt_italic('The long hex string like a1b2c3d4-e5f6-...', m)}",
        )

    elif step == "user_id":
        url = state["url"]
        token = state["token"]
        host._wizard_state.pop(recipient_id, None)
        await host.wizard_send(recipient_id, "Testing Jellyfin connection...")
        await wizard_jellyfin_save(host, recipient_id, url, token, text)


async def wizard_jellyfin_save(
    host: WizardHost,
    recipient_id: str,
    url: str,
    token: str,
    user_id: str,
):
    url = url.rstrip("/")
    env_vars = {
        "JELLYFIN_URL": url,
        "JELLYFIN_TOKEN": token,
        "JELLYFIN_USER_ID": user_id,
    }
    for k, v in env_vars.items():
        os.environ[k] = v
    env_path = Path.home() / ".familiar" / ".env"
    try:
        _update_env_file(env_path, env_vars)
    except Exception as e:
        await host.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
        return

    await host.wizard_send(recipient_id, "Testing Jellyfin connection...")
    await wizard_jellyfin_test(host, recipient_id)


async def wizard_jellyfin_test(host: WizardHost, recipient_id: str):
    m = host.format_mode
    url = os.environ.get("JELLYFIN_URL", "")
    token = os.environ.get("JELLYFIN_TOKEN", "")
    if not url or not token:
        await host.wizard_send(
            recipient_id,
            f"\u274c No Jellyfin credentials configured.\nRun: {fmt_code('/connect jellyfin', m)}",
        )
        return
    ok, msg = _test_http_service(
        f"{url}/Users",
        headers={"X-Emby-Token": token},
    )
    if ok:
        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('Jellyfin connected!', m)}\n\n"
            f"  Server: {fmt_code(url, m)}\n"
            f"  Status: {msg}\n\n"
            f"Saved to: {fmt_code('~/.familiar/.env', m)}",
        )
    else:
        await host.wizard_send(
            recipient_id,
            f"\u274c {fmt_bold('Jellyfin connection failed', m)}\n\n"
            f"  {msg}\n\n"
            f"Check your URL and API token.",
        )


# ── Self-hosted: Gitea ───────────────────────────────────────────


async def wizard_gitea(host: WizardHost, recipient_id: str, args: list):
    m = host.format_mode
    sub = args[0] if args else ""

    if sub == "test":
        await wizard_gitea_test(host, recipient_id)
        return

    if sub.startswith("http") and len(args) >= 2:
        await wizard_gitea_save(host, recipient_id, args[0], args[1])
        return

    has_url = os.environ.get("GITEA_URL", "")
    has_token = bool(os.environ.get("GITEA_TOKEN"))

    if has_url and has_token:
        await host.wizard_send(
            recipient_id,
            f"\U0001f419 {fmt_bold('Forgejo / Gitea Configuration', m)}\n\n"
            f"  \u2705 URL: {fmt_code(has_url, m)}\n"
            f"  \u2705 Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n\n"
            f"{fmt_bold('Commands:', m)}\n"
            f"  {fmt_code('/connect forgejo test', m)} \u2014 test connection\n",
        )
        return

    blurb = _service_doc_blurb("gitea", m)
    if host.supports_buttons:
        await start_gitea_wizard(host, recipient_id)
    else:
        await host.wizard_send(
            recipient_id,
            f"\U0001f419 {fmt_bold('Connect Forgejo / Gitea', m)}\n"
            f"{blurb}\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Go to Settings \u2192 Applications \u2192 Generate Token\n"
            f"2. Copy the access token\n\n"
            f"{fmt_bold('One-shot setup (2 values, space-separated):', m)}\n"
            f"  {fmt_code('/connect forgejo <URL> <TOKEN>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect forgejo https://forge.example.org gta_abc123...', m)}\n\n"
            f"{fmt_italic('Works with both Forgejo and Gitea instances.', m)}",
        )


async def start_gitea_wizard(host: WizardHost, recipient_id: str):
    host._ensure_wizard_state()
    m = host.format_mode
    host._wizard_state[recipient_id] = {
        "type": "gitea",
        "step": "url",
    }
    blurb = _service_doc_blurb("gitea", m)
    await host.wizard_send(
        recipient_id,
        f"\U0001f419 {fmt_bold('Forgejo / Gitea Setup', m)}\n"
        f"{blurb}\n"
        f"This wizard will configure your Forgejo or Gitea instance.\n\n"
        f"Enter your {fmt_bold('instance URL', m)}:\n\n"
        f"{fmt_italic('Example: https://forge.example.org', m)}",
    )


async def gitea_handle_step(
    host: WizardHost,
    recipient_id: str,
    text: str,
    message_ref,
):
    host._ensure_wizard_state()
    m = host.format_mode
    state = host._wizard_state.get(recipient_id)
    if not state:
        return
    step = state["step"]
    text = text.strip()

    if step == "url":
        if not text.startswith(("http://", "https://")):
            await host.wizard_send(
                recipient_id,
                "That doesn't look like a URL. "
                "Please enter a URL starting with http:// or https://:",
            )
            return
        state["url"] = text.rstrip("/")
        state["step"] = "token"

        warning = ""
        if not host.supports_message_deletion:
            pw_warning = (
                "This channel cannot delete messages. "
                "Your token will remain visible in chat history. "
                "Consider using one-shot: /connect gitea <URL> <TOKEN>"
            )
            warning = f"\n\n\u26a0\ufe0f {fmt_italic(pw_warning, m)}"

        await host.wizard_send(
            recipient_id,
            f"\U0001f510 Enter your {fmt_bold('access token', m)}:\n\n"
            f"Create one at: Settings \u2192 Applications \u2192 Generate Token"
            f"{warning}"
            + (
                "\n\n"
                + fmt_italic(
                    "Your message will be deleted immediately for safety.",
                    m,
                )
                if host.supports_message_deletion
                else ""
            ),
        )

    elif step == "token":
        if host.supports_message_deletion and message_ref is not None:
            try:
                await host.wizard_delete_message(recipient_id, message_ref)
            except Exception:
                pass

        url = state["url"]
        host._wizard_state.pop(recipient_id, None)
        await host.wizard_send(recipient_id, "Testing Gitea connection...")
        await wizard_gitea_save(host, recipient_id, url, text)


async def wizard_gitea_save(
    host: WizardHost,
    recipient_id: str,
    url: str,
    token: str,
):
    url = url.rstrip("/")
    env_vars = {"GITEA_URL": url, "GITEA_TOKEN": token}
    for k, v in env_vars.items():
        os.environ[k] = v
    env_path = Path.home() / ".familiar" / ".env"
    try:
        _update_env_file(env_path, env_vars)
    except Exception as e:
        await host.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
        return

    await host.wizard_send(recipient_id, "Testing Gitea connection...")
    await wizard_gitea_test(host, recipient_id)


async def wizard_gitea_test(host: WizardHost, recipient_id: str):
    m = host.format_mode
    url = os.environ.get("GITEA_URL", "")
    token = os.environ.get("GITEA_TOKEN", "")
    if not url or not token:
        await host.wizard_send(
            recipient_id,
            f"\u274c No Forgejo / Gitea credentials configured.\n"
            f"Run: {fmt_code('/connect forgejo', m)}",
        )
        return
    ok, msg = _test_http_service(
        f"{url}/api/v1/repos/search",
        headers={"Authorization": f"token {token}"},
    )
    if ok:
        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('Forgejo / Gitea connected!', m)}\n\n"
            f"  Server: {fmt_code(url, m)}\n"
            f"  Status: {msg}\n\n"
            f"Saved to: {fmt_code('~/.familiar/.env', m)}",
        )
    else:
        await host.wizard_send(
            recipient_id,
            f"\u274c {fmt_bold('Connection failed', m)}\n\n"
            f"  {msg}\n\n"
            f"Check your URL and access token.",
        )


# ── Self-hosted: Home Assistant ──────────────────────────────────


async def wizard_homeassistant(host: WizardHost, recipient_id: str, args: list):
    m = host.format_mode
    sub = args[0] if args else ""

    if sub == "test":
        await wizard_homeassistant_test(host, recipient_id)
        return

    if sub.startswith("http") and len(args) >= 2:
        await wizard_homeassistant_save(host, recipient_id, args[0], args[1])
        return

    has_url = os.environ.get("HOMEASSISTANT_URL", "")
    has_token = bool(os.environ.get("HOMEASSISTANT_TOKEN"))

    if has_url and has_token:
        await host.wizard_send(
            recipient_id,
            f"\U0001f3e0 {fmt_bold('Home Assistant Configuration', m)}\n\n"
            f"  \u2705 URL: {fmt_code(has_url, m)}\n"
            f"  \u2705 Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n\n"
            f"{fmt_bold('Commands:', m)}\n"
            f"  {fmt_code('/connect homeassistant test', m)} \u2014 test connection\n",
        )
        return

    blurb = _service_doc_blurb("homeassistant", m)
    if host.supports_buttons:
        await start_homeassistant_wizard(host, recipient_id)
    else:
        await host.wizard_send(
            recipient_id,
            f"\U0001f3e0 {fmt_bold('Connect Home Assistant', m)}\n"
            f"{blurb}\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Open Home Assistant \u2192 Profile (bottom-left)\n"
            f"2. Scroll to Long-Lived Access Tokens \u2192 Create Token\n"
            f"3. Copy the token\n\n"
            f"{fmt_bold('One-shot setup (2 values, space-separated):', m)}\n"
            f"  {fmt_code('/connect homeassistant <URL> <TOKEN>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect homeassistant http://homeassistant.local:8123 eyJ0...', m)}\n\n"
            f"{fmt_italic('Aliases: /connect hass, /connect home-assistant', m)}",
        )


async def start_homeassistant_wizard(host: WizardHost, recipient_id: str):
    host._ensure_wizard_state()
    m = host.format_mode
    host._wizard_state[recipient_id] = {
        "type": "homeassistant",
        "step": "url",
    }
    blurb = _service_doc_blurb("homeassistant", m)
    await host.wizard_send(
        recipient_id,
        f"\U0001f3e0 {fmt_bold('Home Assistant Setup', m)}\n"
        f"{blurb}\n"
        f"This wizard will configure your Home Assistant instance.\n\n"
        f"Enter your {fmt_bold('Home Assistant URL', m)}:\n\n"
        f"{fmt_italic('Example: http://homeassistant.local:8123', m)}",
    )


async def homeassistant_handle_step(
    host: WizardHost,
    recipient_id: str,
    text: str,
    message_ref,
):
    host._ensure_wizard_state()
    m = host.format_mode
    state = host._wizard_state.get(recipient_id)
    if not state:
        return
    step = state["step"]
    text = text.strip()

    if step == "url":
        if not text.startswith(("http://", "https://")):
            await host.wizard_send(
                recipient_id,
                "That doesn't look like a URL. "
                "Please enter a URL starting with http:// or https://:",
            )
            return
        state["url"] = text.rstrip("/")
        state["step"] = "token"

        warning = ""
        if not host.supports_message_deletion:
            pw_warning = (
                "This channel cannot delete messages. "
                "Your token will remain visible in chat history. "
                "Consider using one-shot: /connect homeassistant <URL> <TOKEN>"
            )
            warning = f"\n\n\u26a0\ufe0f {fmt_italic(pw_warning, m)}"

        await host.wizard_send(
            recipient_id,
            f"\U0001f510 Enter your {fmt_bold('Long-Lived Access Token', m)}:\n\n"
            f"Create one at: Profile \u2192 Long-Lived Access Tokens \u2192 Create Token"
            f"{warning}"
            + (
                "\n\n"
                + fmt_italic(
                    "Your message will be deleted immediately for safety.",
                    m,
                )
                if host.supports_message_deletion
                else ""
            ),
        )

    elif step == "token":
        if host.supports_message_deletion and message_ref is not None:
            try:
                await host.wizard_delete_message(recipient_id, message_ref)
            except Exception:
                pass

        url = state["url"]
        host._wizard_state.pop(recipient_id, None)
        await host.wizard_send(recipient_id, "Testing Home Assistant connection...")
        await wizard_homeassistant_save(host, recipient_id, url, text)


async def wizard_homeassistant_save(
    host: WizardHost,
    recipient_id: str,
    url: str,
    token: str,
):
    url = url.rstrip("/")
    env_vars = {
        "HOMEASSISTANT_URL": url,
        "HOMEASSISTANT_TOKEN": token,
    }
    for k, v in env_vars.items():
        os.environ[k] = v
    env_path = Path.home() / ".familiar" / ".env"
    try:
        _update_env_file(env_path, env_vars)
    except Exception as e:
        await host.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
        return

    await host.wizard_send(recipient_id, "Testing Home Assistant connection...")
    await wizard_homeassistant_test(host, recipient_id)


async def wizard_homeassistant_test(host: WizardHost, recipient_id: str):
    m = host.format_mode
    url = os.environ.get("HOMEASSISTANT_URL", "")
    token = os.environ.get("HOMEASSISTANT_TOKEN", "")
    if not url or not token:
        await host.wizard_send(
            recipient_id,
            f"\u274c No Home Assistant credentials configured.\n"
            f"Run: {fmt_code('/connect homeassistant', m)}",
        )
        return
    ok, msg = _test_http_service(
        f"{url}/api/",
        headers={"Authorization": f"Bearer {token}"},
    )
    if ok:
        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('Home Assistant connected!', m)}\n\n"
            f"  Server: {fmt_code(url, m)}\n"
            f"  Status: {msg}\n\n"
            f"Saved to: {fmt_code('~/.familiar/.env', m)}",
        )
    else:
        await host.wizard_send(
            recipient_id,
            f"\u274c {fmt_bold('Home Assistant connection failed', m)}\n\n"
            f"  {msg}\n\n"
            f"Check your URL and long-lived access token.",
        )


# ── Self-hosted: Joplin ──────────────────────────────────────────


def _joplin_find_binary() -> str | None:
    """Check if Joplin desktop is installed."""
    import shutil

    # Native binary
    for name in ("joplin-desktop", "joplin"):
        path = shutil.which(name)
        if path:
            return path
    # Flatpak
    try:
        import subprocess

        result = subprocess.run(
            ["flatpak", "info", "net.cozic.joplin_desktop"],
            capture_output=True,
            timeout=10,
        )
        if result.returncode == 0:
            return "flatpak run net.cozic.joplin_desktop"
    except Exception:
        pass
    # Snap
    snap_path = Path("/snap/joplin-desktop/current")
    if snap_path.exists():
        return "joplin-desktop"
    # AppImage in common locations
    for d in (Path.home(), Path.home() / "Applications", Path.home() / "Downloads"):
        for f in d.glob("Joplin*.AppImage"):
            return str(f)
    return None


async def _joplin_install(host: WizardHost, recipient_id: str) -> bool:
    """Auto-install Joplin via flatpak. Returns True on success."""
    import shutil

    m = host.format_mode
    if not shutil.which("flatpak"):
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f Flatpak not available for automatic install.\n\n"
            f"{fmt_bold('Install Joplin manually:', m)}\n"
            f"  {fmt_code('sudo apt install flatpak', m)}\n"
            f"  {fmt_code('flatpak install flathub net.cozic.joplin_desktop', m)}\n\n"
            f"Or download from: https://joplinapp.org/download/",
        )
        return False

    # Ensure flathub remote is configured for --user installs
    try:
        await asyncio.create_subprocess_exec(
            "flatpak",
            "remote-add",
            "--user",
            "--if-not-exists",
            "flathub",
            "https://flathub.org/repo/flathub.flatpakrepo",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
    except Exception:
        pass

    await host.wizard_send(
        recipient_id,
        "\U0001f4e6 Installing Joplin via Flatpak... (this may take a few minutes)",
    )
    try:
        proc = await asyncio.create_subprocess_exec(
            "flatpak",
            "install",
            "--user",
            "-y",
            "flathub",
            "net.cozic.joplin_desktop",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        _, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
        if proc.returncode == 0:
            await host.wizard_send(
                recipient_id,
                f"\u2705 Joplin installed!\n\n"
                f"{fmt_bold('Next steps:', m)}\n"
                f"1. Launch Joplin:\n"
                f"   {fmt_code('flatpak run net.cozic.joplin_desktop', m)}\n"
                f"2. Go to Options \u2192 Web Clipper\n"
                f"3. Enable the Web Clipper service\n"
                f"4. Copy the authorization token\n"
                f"5. Come back and run:\n"
                f"   {fmt_code('/connect joplin <TOKEN>', m)}",
            )
            return True
        err_msg = stderr.decode(errors="replace").strip()[-200:]
        await host.wizard_send(
            recipient_id,
            f"\u274c Flatpak install failed:\n{fmt_code(err_msg, m)}\n\n"
            f"Try manually: https://joplinapp.org/download/",
        )
        return False
    except asyncio.TimeoutError:
        await host.wizard_send(
            recipient_id,
            "\u274c Install timed out. Try manually.",
        )
        return False
    except Exception as e:
        await host.wizard_send(
            recipient_id,
            f"\u274c Install error: {e}",
        )
        return False


async def wizard_joplin(host: WizardHost, recipient_id: str, args: list):
    m = host.format_mode
    sub = args[0] if args else ""

    if sub == "test":
        await wizard_joplin_test(host, recipient_id)
        return
    if sub == "install":
        await _joplin_install(host, recipient_id)
        return

    # /connect joplin <TOKEN> [URL]
    if args and not sub.startswith("http") and sub != "test":
        token = args[0]
        url = args[1] if len(args) > 1 else "http://localhost:41184"
        await wizard_joplin_save(host, recipient_id, token, url)
        return

    has_token = bool(os.environ.get("JOPLIN_TOKEN"))
    has_url = os.environ.get("JOPLIN_URL", "http://localhost:41184")

    if has_token:
        await host.wizard_send(
            recipient_id,
            f"\U0001f4dd {fmt_bold('Joplin Configuration', m)}\n\n"
            f"  \u2705 URL: {fmt_code(has_url, m)}\n"
            f"  \u2705 Token: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n\n"
            f"{fmt_bold('Commands:', m)}\n"
            f"  {fmt_code('/connect joplin test', m)} \u2014 test connection\n",
        )
        return

    # Check if Joplin is installed
    joplin_bin = _joplin_find_binary()
    if not joplin_bin:
        # Offer to install
        options = [
            ("joplin_install", "Install Joplin"),
            ("joplin_manual", "I'll install manually"),
        ]
        await host.wizard_send_menu(
            recipient_id,
            f"\U0001f4dd {fmt_bold('Connect Joplin', m)}\n\n"
            f"Joplin is not installed on this system.\n\n"
            f"Familiar can install it via Flatpak, or you can "
            f"install it yourself from https://joplinapp.org/download/",
            options,
        )
        return

    blurb = _service_doc_blurb("joplin", m)
    if host.supports_buttons:
        await start_joplin_wizard(host, recipient_id)
    else:
        await host.wizard_send(
            recipient_id,
            f"\U0001f4dd {fmt_bold('Connect Joplin', m)}\n"
            f"{blurb}\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Open Joplin desktop \u2192 Options \u2192 Web Clipper\n"
            f"2. Enable the Web Clipper service\n"
            f"3. Copy the authorization token\n\n"
            f"{fmt_bold('One-shot setup:', m)}\n"
            f"  {fmt_code('/connect joplin <TOKEN> [URL]', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect joplin abc123def456', m)}\n"
            f"  {fmt_code('/connect joplin abc123 http://192.168.1.5:41184', m)}\n\n"
            f"{fmt_italic('URL defaults to http://localhost:41184', m)}",
        )


async def start_joplin_wizard(host: WizardHost, recipient_id: str):
    host._ensure_wizard_state()
    m = host.format_mode
    host._wizard_state[recipient_id] = {
        "type": "joplin",
        "step": "token",
    }
    blurb = _service_doc_blurb("joplin", m)
    await host.wizard_send(
        recipient_id,
        f"\U0001f4dd {fmt_bold('Joplin Setup', m)}\n"
        f"{blurb}\n"
        f"This wizard will configure your Joplin note-taking app.\n\n"
        f"\U0001f510 Enter your {fmt_bold('Joplin Web Clipper token', m)}:\n\n"
        f"Find it at: Options \u2192 Web Clipper \u2192 Authorization token"
        + (
            "\n\n"
            + fmt_italic(
                "Your message will be deleted immediately for safety.",
                m,
            )
            if host.supports_message_deletion
            else ""
        ),
    )


async def joplin_handle_step(
    host: WizardHost,
    recipient_id: str,
    text: str,
    message_ref,
):
    host._ensure_wizard_state()
    m = host.format_mode
    state = host._wizard_state.get(recipient_id)
    if not state:
        return
    step = state["step"]
    text = text.strip()

    if step == "token":
        if host.supports_message_deletion and message_ref is not None:
            try:
                await host.wizard_delete_message(recipient_id, message_ref)
            except Exception:
                pass

        state["token"] = text
        state["step"] = "url"
        _hint = 'Type "default" or "skip" to use the default URL.'
        await host.wizard_send(
            recipient_id,
            f"Enter your {fmt_bold('Joplin Web Clipper URL', m)}:\n\n"
            f"Default: {fmt_code('http://localhost:41184', m)}\n\n"
            f"{fmt_italic(_hint, m)}",
        )

    elif step == "url":
        if text.lower() in ("default", "skip"):
            url = "http://localhost:41184"
        elif text.startswith(("http://", "https://")):
            url = text.rstrip("/")
        else:
            await host.wizard_send(
                recipient_id,
                "That doesn't look like a URL. "
                "Please enter a URL starting with http:// or https://, "
                'or type "default" to use localhost:',
            )
            return

        token = state["token"]
        host._wizard_state.pop(recipient_id, None)
        await host.wizard_send(recipient_id, "Testing Joplin connection...")
        await wizard_joplin_save(host, recipient_id, token, url)


async def wizard_joplin_save(
    host: WizardHost,
    recipient_id: str,
    token: str,
    url: str,
):
    url = url.rstrip("/")
    env_vars = {"JOPLIN_TOKEN": token, "JOPLIN_URL": url}
    for k, v in env_vars.items():
        os.environ[k] = v
    env_path = Path.home() / ".familiar" / ".env"
    try:
        _update_env_file(env_path, env_vars)
    except Exception as e:
        await host.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
        return

    await host.wizard_send(recipient_id, "Testing Joplin connection...")
    await wizard_joplin_test(host, recipient_id)


async def wizard_joplin_test(host: WizardHost, recipient_id: str):
    m = host.format_mode
    token = os.environ.get("JOPLIN_TOKEN", "")
    url = os.environ.get("JOPLIN_URL", "http://localhost:41184")
    if not token:
        await host.wizard_send(
            recipient_id,
            f"\u274c No Joplin token configured.\nRun: {fmt_code('/connect joplin', m)}",
        )
        return
    ok, msg = _test_http_service(
        f"{url}/folders",
        params={"token": token},
    )
    if ok:
        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('Joplin connected!', m)}\n\n"
            f"  URL: {fmt_code(url, m)}\n"
            f"  Status: {msg}\n\n"
            f"Saved to: {fmt_code('~/.familiar/.env', m)}",
        )
    else:
        await host.wizard_send(
            recipient_id,
            f"\u274c {fmt_bold('Joplin connection failed', m)}\n\n"
            f"  {msg}\n\n"
            f"Make sure Joplin is running and the Web Clipper is enabled.",
        )


# ── Self-hosted: Pi-hole / AdGuard ───────────────────────────────


async def wizard_pihole(host: WizardHost, recipient_id: str, args: list):
    m = host.format_mode
    sub = args[0] if args else ""

    if sub == "test":
        await wizard_pihole_test(host, recipient_id)
        return

    if sub.startswith("http") and len(args) >= 2:
        pihole_type = "adguard" if (len(args) > 2 and args[2].lower() == "adguard") else "pihole"
        await wizard_pihole_save(host, recipient_id, args[0], args[1], pihole_type)
        return

    has_url = os.environ.get("PIHOLE_URL", "")
    has_token = bool(os.environ.get("PIHOLE_TOKEN"))
    pihole_type = os.environ.get("PIHOLE_TYPE", "pihole")

    if has_url and has_token:
        await host.wizard_send(
            recipient_id,
            f"\U0001f6e1\ufe0f {fmt_bold('Pi-hole / AdGuard Configuration', m)}\n\n"
            f"  \u2705 URL: {fmt_code(has_url, m)}\n"
            f"  \u2705 Token/Auth: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n"
            f"  Type: {fmt_bold(pihole_type, m)}\n\n"
            f"{fmt_bold('Commands:', m)}\n"
            f"  {fmt_code('/connect pihole test', m)} \u2014 test connection\n",
        )
        return

    blurb = _service_doc_blurb("pihole", m)
    if host.supports_buttons:
        await start_pihole_wizard(host, recipient_id)
    else:
        _hint = 'Add "adguard" at the end for AdGuard Home instances.'
        await host.wizard_send(
            recipient_id,
            f"\U0001f6e1\ufe0f {fmt_bold('Connect Pi-hole / AdGuard', m)}\n"
            f"{blurb}\n"
            f"{fmt_bold('Pi-hole setup:', m)}\n"
            f"1. Open Pi-hole Admin \u2192 Settings \u2192 API\n"
            f"2. Copy the API token (or set one)\n\n"
            f"{fmt_bold('AdGuard Home setup:', m)}\n"
            f"1. Note your AdGuard admin username and password\n"
            f"2. Format as {fmt_code('username:password', m)} for the token field\n\n"
            f"{fmt_bold('One-shot setup:', m)}\n"
            f"  {fmt_code('/connect pihole <URL> <TOKEN> [adguard]', m)}\n\n"
            f"{fmt_bold('Examples:', m)}\n"
            f"  {fmt_code('/connect pihole http://pi.hole abc123', m)}\n"
            f"  {fmt_code('/connect pihole http://192.168.1.1:3000 admin:pass adguard', m)}\n\n"
            f"{fmt_italic(_hint, m)}",
        )


async def start_pihole_wizard(host: WizardHost, recipient_id: str):
    host._ensure_wizard_state()
    m = host.format_mode
    host._wizard_state[recipient_id] = {
        "type": "pihole",
        "step": "url",
    }
    blurb = _service_doc_blurb("pihole", m)
    await host.wizard_send(
        recipient_id,
        f"\U0001f6e1\ufe0f {fmt_bold('Pi-hole / AdGuard Setup', m)}\n"
        f"{blurb}\n"
        f"This wizard will configure your DNS ad-blocker.\n\n"
        f"Enter your {fmt_bold('Pi-hole or AdGuard Home URL', m)}:\n\n"
        f"{fmt_italic('Example: http://pi.hole or http://192.168.1.1:3000', m)}",
    )


async def pihole_handle_step(
    host: WizardHost,
    recipient_id: str,
    text: str,
    message_ref,
):
    host._ensure_wizard_state()
    m = host.format_mode
    state = host._wizard_state.get(recipient_id)
    if not state:
        return
    step = state["step"]
    text = text.strip()

    if step == "url":
        if not text.startswith(("http://", "https://")):
            await host.wizard_send(
                recipient_id,
                "That doesn't look like a URL. "
                "Please enter a URL starting with http:// or https://:",
            )
            return
        state["url"] = text.rstrip("/")
        state["step"] = "token"

        warning = ""
        if not host.supports_message_deletion:
            pw_warning = (
                "This channel cannot delete messages. "
                "Your token will remain visible in chat history. "
                "Consider using one-shot: /connect pihole <URL> <TOKEN> [adguard]"
            )
            warning = f"\n\n\u26a0\ufe0f {fmt_italic(pw_warning, m)}"

        await host.wizard_send(
            recipient_id,
            f"\U0001f510 Enter your {fmt_bold('API token or credentials', m)}:\n\n"
            f"Pi-hole: API token from Settings \u2192 API\n"
            f"AdGuard: {fmt_code('username:password', m)}"
            f"{warning}"
            + (
                "\n\n"
                + fmt_italic(
                    "Your message will be deleted immediately for safety.",
                    m,
                )
                if host.supports_message_deletion
                else ""
            ),
        )

    elif step == "token":
        if host.supports_message_deletion and message_ref is not None:
            try:
                await host.wizard_delete_message(recipient_id, message_ref)
            except Exception:
                pass

        state["token"] = text
        state["step"] = "type"

        if host.supports_buttons:
            await host.wizard_send_menu(
                recipient_id,
                f"Select your {fmt_bold('DNS blocker type', m)}:",
                [
                    (
                        "pihole_type_pihole",
                        "\U0001f6e1\ufe0f Pi-hole",
                    ),
                    (
                        "pihole_type_adguard",
                        "\U0001f6e1\ufe0f AdGuard Home",
                    ),
                ],
            )
        else:
            await host.wizard_send(
                recipient_id,
                f"Which type is this?\n\n"
                f"  1. Pi-hole\n"
                f"  2. AdGuard Home\n\n"
                f"Reply with {fmt_bold('1', m)} or {fmt_bold('2', m)} "
                f"(or type {fmt_code('pihole', m)} / {fmt_code('adguard', m)}):",
            )

    elif step == "type":
        lower = text.lower()
        if lower in ("1", "pihole", "pi-hole"):
            pihole_type = "pihole"
        elif lower in ("2", "adguard", "adguardhome"):
            pihole_type = "adguard"
        else:
            await host.wizard_send(
                recipient_id,
                f"Please reply with {fmt_bold('1', m)} (Pi-hole) or {fmt_bold('2', m)} (AdGuard):",
            )
            return

        url = state["url"]
        token = state["token"]
        host._wizard_state.pop(recipient_id, None)
        await host.wizard_send(
            recipient_id,
            f"Testing {pihole_type} connection...",
        )
        await wizard_pihole_save(host, recipient_id, url, token, pihole_type)


async def wizard_pihole_save(
    host: WizardHost,
    recipient_id: str,
    url: str,
    token: str,
    pihole_type: str,
):
    url = url.rstrip("/")
    env_vars = {
        "PIHOLE_URL": url,
        "PIHOLE_TOKEN": token,
        "PIHOLE_TYPE": pihole_type,
    }
    for k, v in env_vars.items():
        os.environ[k] = v
    env_path = Path.home() / ".familiar" / ".env"
    try:
        _update_env_file(env_path, env_vars)
    except Exception as e:
        await host.wizard_send(recipient_id, f"\u26a0\ufe0f Failed to save: {e}")
        return

    await host.wizard_send(recipient_id, f"Testing {pihole_type} connection...")
    await wizard_pihole_test(host, recipient_id)


async def wizard_pihole_test(host: WizardHost, recipient_id: str):
    m = host.format_mode
    url = os.environ.get("PIHOLE_URL", "")
    token = os.environ.get("PIHOLE_TOKEN", "")
    pihole_type = os.environ.get("PIHOLE_TYPE", "pihole")
    if not url or not token:
        await host.wizard_send(
            recipient_id,
            f"\u274c No Pi-hole/AdGuard credentials configured.\n"
            f"Run: {fmt_code('/connect pihole', m)}",
        )
        return
    if pihole_type == "adguard":
        parts = token.split(":", 1)
        user = parts[0]
        passwd = parts[1] if len(parts) > 1 else ""
        ok, msg = _test_http_service(
            f"{url}/control/status",
            auth=(user, passwd),
        )
    else:
        ok, msg = _test_http_service(
            f"{url}/admin/api.php",
            params={"status": "", "auth": token},
        )
    if ok:
        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold(f'{pihole_type.title()} connected!', m)}\n\n"
            f"  Server: {fmt_code(url, m)}\n"
            f"  Status: {msg}\n\n"
            f"Saved to: {fmt_code('~/.familiar/.env', m)}",
        )
    else:
        await host.wizard_send(
            recipient_id,
            f"\u274c {fmt_bold(f'{pihole_type.title()} connection failed', m)}\n\n"
            f"  {msg}\n\n"
            f"Check your URL and authentication.",
        )


# ── Self-hosted: Nextcloud (multi-step) ──────────────────────────


async def wizard_nextcloud(host: WizardHost, recipient_id: str, args: list):
    m = host.format_mode
    sub = args[0] if args else ""

    if sub == "test":
        await wizard_nextcloud_test(host, recipient_id)
        return

    # One-shot: /connect nextcloud <URL> <USER> <TOKEN>
    if sub.startswith("http") and len(args) >= 3:
        await nextcloud_save_and_test(host, recipient_id, args[0], args[1], args[2])
        return

    has_url = os.environ.get("NEXTCLOUD_URL", "")
    has_user = bool(os.environ.get("NEXTCLOUD_USER"))
    has_token = bool(os.environ.get("NEXTCLOUD_TOKEN"))

    if has_url and has_user and has_token:
        await host.wizard_send(
            recipient_id,
            f"\u2601\ufe0f {fmt_bold('Nextcloud Configuration', m)}\n\n"
            f"  \u2705 URL: {fmt_code(has_url, m)}\n"
            f"  \u2705 User: {fmt_code(os.environ.get('NEXTCLOUD_USER', ''), m)}\n"
            f"  \u2705 App Password: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\n\n"
            f"{fmt_bold('Commands:', m)}\n"
            f"  {fmt_code('/connect nextcloud test', m)} \u2014 test connection\n",
        )
        return

    # Start interactive wizard or show instructions
    blurb = _service_doc_blurb("nextcloud", m)
    if host.supports_buttons:
        await start_nextcloud_wizard(host, recipient_id)
    else:
        await host.wizard_send(
            recipient_id,
            f"\u2601\ufe0f {fmt_bold('Connect Nextcloud', m)}\n"
            f"{blurb}\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Open Nextcloud \u2192 Settings \u2192 Security\n"
            f"2. Create an App password (name it 'Familiar')\n"
            f"3. Copy the generated password\n\n"
            f"{fmt_bold('One-shot setup (3 values, space-separated):', m)}\n"
            f"  {fmt_code('/connect nextcloud <URL> <USER> <APP_PASSWORD>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect nextcloud https://cloud.example.org alice AbCdE-fGhIj-KlMnO-pQrSt-uVwXy', m)}\n\n"
            f"{fmt_italic('Also configures CalDAV calendar automatically.', m)}",
        )


async def start_nextcloud_wizard(host: WizardHost, recipient_id: str):
    host._ensure_wizard_state()
    m = host.format_mode
    host._wizard_state[recipient_id] = {
        "type": "nextcloud",
        "step": "url",
    }
    blurb = _service_doc_blurb("nextcloud", m)
    await host.wizard_send(
        recipient_id,
        f"\u2601\ufe0f {fmt_bold('Nextcloud Setup', m)}\n"
        f"{blurb}\n"
        f"Nextcloud provides files, calendar, contacts, and more.\n"
        f"This wizard will configure access and auto-setup CalDAV.\n\n"
        f"Enter your {fmt_bold('Nextcloud URL', m)}:\n\n"
        f"{fmt_italic('Example: https://cloud.example.org', m)}",
    )


async def nextcloud_handle_step(
    host: WizardHost,
    recipient_id: str,
    text: str,
    message_ref,
):
    host._ensure_wizard_state()
    m = host.format_mode
    state = host._wizard_state.get(recipient_id)
    if not state:
        return
    step = state["step"]
    text = text.strip()

    if step == "url":
        if not text.startswith(("http://", "https://")):
            await host.wizard_send(
                recipient_id,
                "That doesn't look like a URL. "
                "Please enter a URL starting with http:// or https://:",
            )
            return
        state["url"] = text.rstrip("/")
        state["step"] = "user"
        await host.wizard_send(
            recipient_id,
            f"Enter your {fmt_bold('Nextcloud username', m)}:",
        )

    elif step == "user":
        if not text:
            await host.wizard_send(
                recipient_id,
                "Username cannot be empty. Please try again:",
            )
            return
        state["user"] = text
        state["step"] = "password"

        warning = ""
        if not host.supports_message_deletion:
            pw_warning = (
                "This channel cannot delete messages. "
                "Your password will remain visible in chat history. "
                "Consider using one-shot: /connect nextcloud <URL> <USER> <PASS>"
            )
            warning = f"\n\n\u26a0\ufe0f {fmt_italic(pw_warning, m)}"

        await host.wizard_send(
            recipient_id,
            f"\U0001f510 Enter your {fmt_bold('Nextcloud App password', m)}:\n\n"
            f"Create one at: Settings \u2192 Security \u2192 App passwords"
            f"{warning}"
            + (
                "\n\n"
                + fmt_italic(
                    "Your message will be deleted immediately for safety.",
                    m,
                )
                if host.supports_message_deletion
                else ""
            ),
        )

    elif step == "password":
        if host.supports_message_deletion and message_ref is not None:
            try:
                await host.wizard_delete_message(recipient_id, message_ref)
            except Exception:
                pass

        url = state["url"]
        user = state["user"]
        host._wizard_state.pop(recipient_id, None)
        await host.wizard_send(recipient_id, "Testing Nextcloud connection...")
        await nextcloud_save_and_test(host, recipient_id, url, user, text)


async def nextcloud_save_and_test(
    host: WizardHost,
    recipient_id: str,
    url: str,
    user: str,
    token: str,
):
    m = host.format_mode
    url = url.rstrip("/")
    env_vars = {
        "NEXTCLOUD_URL": url,
        "NEXTCLOUD_USER": user,
        "NEXTCLOUD_TOKEN": token,
    }
    for k, v in env_vars.items():
        os.environ[k] = v
    env_path = Path.home() / ".familiar" / ".env"
    try:
        _update_env_file(env_path, env_vars)
    except Exception as e:
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f Credentials set for this session but failed to persist: {e}",
        )
        return

    # Auto-derive CalDAV credentials from Nextcloud
    caldav_url = f"{url}/remote.php/dav/calendars/{user}/"
    caldav_vars = {
        "CALDAV_URL": caldav_url,
        "CALDAV_USER": user,
        "CALDAV_PASSWORD": token,
        "CALENDAR_PROVIDER": "caldav",
    }
    for k, v in caldav_vars.items():
        os.environ[k] = v
    try:
        _update_env_file(env_path, caldav_vars)
    except Exception:
        pass

    # Register CalDAV account in calendar account registry
    try:
        from familiar.skills.calendar.accounts import (
            _load_accounts_file,
            _unique_id,
            add_account,
        )

        data = _load_accounts_file()
        # Avoid duplicates: check if a caldav account with this URL exists
        existing = [
            a for a in data["accounts"] if a.get("type") == "caldav" and a.get("url") == caldav_url
        ]
        if not existing:
            acct_id = _unique_id("caldav", data["accounts"])
            account = {
                "id": acct_id,
                "type": "caldav",
                "label": f"Nextcloud ({user})",
                "url": caldav_url,
                "user": user,
                "password": token,
            }
            add_account(account)
    except Exception as e:
        logger.warning(f"Failed to register Nextcloud CalDAV account: {e}")

    # Test connection via WebDAV
    ok, msg = _test_http_service(
        f"{url}/remote.php/dav/files/{user}/",
        auth=(user, token),
        method="PROPFIND",
    )
    if ok:
        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('Nextcloud connected!', m)}\n\n"
            f"  Server: {fmt_code(url, m)}\n"
            f"  User: {fmt_code(user, m)}\n"
            f"  WebDAV: {msg}\n"
            f"  CalDAV: auto-configured \u2705\n\n"
            f"Saved to: {fmt_code('~/.familiar/.env', m)}",
        )
    else:
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f {fmt_bold('Nextcloud credentials saved', m)} but connection test failed:\n"
            f"  {msg}\n\n"
            f"Check your URL, username, and app password.\n"
            f"To retry: {fmt_code('/connect nextcloud test', m)}",
        )


async def wizard_nextcloud_test(host: WizardHost, recipient_id: str):
    m = host.format_mode
    url = os.environ.get("NEXTCLOUD_URL", "")
    user = os.environ.get("NEXTCLOUD_USER", "")
    token = os.environ.get("NEXTCLOUD_TOKEN", "")
    if not url or not user or not token:
        await host.wizard_send(
            recipient_id,
            f"\u274c No Nextcloud credentials configured.\n"
            f"Run: {fmt_code('/connect nextcloud', m)}",
        )
        return
    ok, msg = _test_http_service(
        f"{url}/remote.php/dav/files/{user}/",
        auth=(user, token),
        method="PROPFIND",
    )
    if ok:
        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('Nextcloud connected!', m)}\n\n"
            f"  Server: {fmt_code(url, m)}\n"
            f"  User: {fmt_code(user, m)}\n"
            f"  Status: {msg}",
        )
    else:
        await host.wizard_send(
            recipient_id,
            f"\u274c {fmt_bold('Nextcloud connection failed', m)}\n\n"
            f"  {msg}\n\n"
            f"Check your URL, username, and app password.",
        )


# ── Self-hosted: Proton (status-only) ────────────────────────────


async def wizard_proton_services(host: WizardHost, recipient_id: str, args: list):
    m = host.format_mode
    sub = args[0] if args else ""

    if sub == "test":
        await wizard_proton_services_test(host, recipient_id)
        return

    bridge_imap = _check_tcp_port("127.0.0.1", 1143)
    bridge_smtp = _check_tcp_port("127.0.0.1", 1025)
    bridge_ok = bridge_imap and bridge_smtp

    import shutil

    has_vpn_cli = shutil.which("protonvpn-cli") is not None

    ck = "\u2705"
    cr = "\u274c"

    lines = [
        f"\U0001f512 {fmt_bold('Proton Services Status', m)}\n",
        f"{fmt_bold('Proton Bridge (Email):', m)}",
        f"  {ck if bridge_imap else cr} IMAP (port 1143)",
        f"  {ck if bridge_smtp else cr} SMTP (port 1025)",
        f"  Status: {fmt_bold('Running', m) if bridge_ok else fmt_bold('Not detected', m)}\n",
        f"{fmt_bold('Proton VPN:', m)}",
        f"  {ck if has_vpn_cli else cr} protonvpn-cli {'found' if has_vpn_cli else 'not found'}\n",
    ]

    if not bridge_ok:
        lines.extend(
            [
                f"{fmt_bold('To start Bridge:', m)}",
                f"  {fmt_code('flatpak run ch.protonmail.protonmail-bridge', m)}",
                "  or install from https://proton.me/mail/bridge\n",
            ]
        )

    if not has_vpn_cli:
        lines.extend(
            [
                f"{fmt_bold('VPN CLI:', m)}",
                f"  Not installed \u2014 tap {fmt_bold('Install VPN CLI', m)} below\n",
            ]
        )

    lines.extend(
        [
            f"{fmt_bold('Proton Drive:', m)}",
            "  Coming soon \u2014 not yet supported\n",
            f"{fmt_bold('Email setup:', m)}",
            f"  Use {fmt_code('/connect email proton', m)} to configure Proton email\n",
            f"{fmt_bold('Diagnostics:', m)}",
            f"  {fmt_code('/connect proton test', m)} \u2014 detailed connection test",
        ]
    )

    options = []
    if has_vpn_cli:
        options.extend(
            [
                ("proton_vpn_connect", "\U0001f517 Connect VPN"),
                (
                    "proton_vpn_disconnect",
                    "\U0001f50c Disconnect VPN",
                ),
                ("proton_vpn_status", "\U0001f4ca VPN Status"),
            ]
        )
    else:
        options.append(("proton_vpn_install", "\U0001f4e6 Install VPN CLI"))

    if options:
        await host.wizard_send_menu(recipient_id, "\n".join(lines), options)
    else:
        await host.wizard_send(recipient_id, "\n".join(lines))


async def wizard_proton_vpn_action(host: WizardHost, recipient_id: str, action: str):
    """Execute a Proton VPN action and display the result."""
    from familiar.skills.proton.skill import (
        proton_vpn_connect,
        proton_vpn_disconnect,
        proton_vpn_status,
    )

    if action == "connect":
        result = proton_vpn_connect({})
    elif action == "disconnect":
        result = proton_vpn_disconnect({})
    else:
        result = proton_vpn_status({})
    await host.wizard_send(recipient_id, result)


async def wizard_proton_services_test(host: WizardHost, recipient_id: str):
    m = host.format_mode
    ck = "\u2705"
    cr = "\u274c"

    bridge_imap = _check_tcp_port("127.0.0.1", 1143)
    bridge_smtp = _check_tcp_port("127.0.0.1", 1025)

    import shutil

    has_vpn_cli = shutil.which("protonvpn-cli") is not None

    vpn_status = "not installed"
    if has_vpn_cli:
        try:
            result = await asyncio.create_subprocess_exec(
                "protonvpn-cli",
                "status",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout, _ = await asyncio.wait_for(result.communicate(), timeout=5)
            vpn_status = stdout.decode().strip()[:200] or "no output"
        except Exception as e:
            vpn_status = f"error: {e}"

    lines = [
        f"\U0001f512 {fmt_bold('Proton Diagnostics', m)}\n",
        f"{fmt_bold('Proton Bridge:', m)}",
        f"  {ck if bridge_imap else cr} IMAP \u2014 127.0.0.1:1143",
        f"  {ck if bridge_smtp else cr} SMTP \u2014 127.0.0.1:1025\n",
        f"{fmt_bold('Proton VPN:', m)}",
        f"  {ck if has_vpn_cli else cr} CLI: {'installed' if has_vpn_cli else 'not found'}",
        f"  Status: {fmt_code(vpn_status, m)}\n",
    ]

    has_email = bool(os.environ.get("EMAIL_ADDRESS"))
    if has_email:
        lines.append(f"  {ck} Email configured: {os.environ.get('EMAIL_ADDRESS', '')}")
    else:
        lines.append(
            f"  {cr} Email not configured \u2014 run {fmt_code('/connect email proton', m)}"
        )

    await host.wizard_send(recipient_id, "\n".join(lines))


# ── Email Server wizard ──────────────────────────────────────────


async def wizard_email_server(host: WizardHost, recipient_id: str, args: list):
    """Self-hosted email server (Mailcow/Mailu/Postfix) setup wizard."""
    m = host.format_mode
    sub = args[0].lower() if args else ""

    if sub == "test":
        await wizard_email_server_test(host, recipient_id)
        return

    # Domain setup: /connect email-server mail.example.com [relay args...]
    if sub and "." in sub:
        domain = sub
        relay_args = args[1:] if len(args) > 1 else []
        await wizard_email_server_save(host, recipient_id, domain, relay_args)
        return

    # Show status or setup instructions
    domain = os.environ.get("EMAIL_SERVER_DOMAIN", "")
    if domain:
        relay = os.environ.get("EMAIL_SERVER_RELAY_HOST", "")
        relay_info = f"\nRelay: {fmt_code(relay, m)}" if relay else ""
        await host.wizard_send(
            recipient_id,
            f"\U0001f4ec {fmt_bold('Email Server', m)}\n\n"
            f"Domain: {fmt_bold(domain, m)}{relay_info}\n\n"
            f"{fmt_bold('Commands:', m)}\n"
            f"/connect email-server test \u2014 check ports & services\n"
            f"/connect email-server <domain> \u2014 change domain\n"
            f"/connect email-server <domain> <relay-host> <user> <pass> \u2014 configure relay\n\n"
            f"{fmt_bold('DNS records needed:', m)}\n"
            f"  MX: {domain} \u2192 mail.{domain}\n"
            f"  SPF: v=spf1 mx ~all\n"
            f"  DMARC: v=DMARC1; p=quarantine",
        )
    else:
        blurb = _service_doc_blurb("email_server", m)
        await host.wizard_send(
            recipient_id,
            f"\U0001f4ec {fmt_bold('Email Server Setup', m)}\n"
            f"{blurb}\n"
            f"Configure a self-hosted email server (Mailcow, Mailu, Postfix, etc.)\n\n"
            f"{fmt_bold('Quick setup:', m)}\n"
            f"  {fmt_code('/connect email-server mail.example.com', m)}\n\n"
            f"{fmt_bold('With relay (optional):', m)}\n"
            f"  {fmt_code('/connect email-server mail.example.com smtp.relay.com user pass', m)}\n\n"
            f"{fmt_bold('What this configures:', m)}\n"
            f"  \u2022 EMAIL_SERVER_DOMAIN in ~/.familiar/.env\n"
            f"  \u2022 Optional SMTP relay for outbound delivery\n"
            f"  \u2022 Enables the email_server skill (admin, search, send)\n\n"
            f"{fmt_bold('Prerequisites:', m)}\n"
            f"  \u2022 A running mail server (Mailcow, Mailu, or Postfix)\n"
            f"  \u2022 DNS records: MX, SPF, DKIM, DMARC\n"
            f"  \u2022 aiosmtpd (auto-installs on first use for local testing)",
        )


async def wizard_email_server_save(
    host: WizardHost,
    recipient_id: str,
    domain: str,
    relay_args: list,
):
    """Save email server domain and optional relay config."""
    m = host.format_mode
    env_path = Path.home() / ".familiar" / ".env"
    env_updates = {"EMAIL_SERVER_DOMAIN": domain}
    os.environ["EMAIL_SERVER_DOMAIN"] = domain

    if len(relay_args) >= 3:
        relay_host = relay_args[0]
        relay_user = relay_args[1]
        relay_pass = relay_args[2]
        env_updates["EMAIL_SERVER_RELAY_HOST"] = relay_host
        env_updates["EMAIL_SERVER_RELAY_USER"] = relay_user
        env_updates["EMAIL_SERVER_RELAY_PASS"] = relay_pass
        os.environ["EMAIL_SERVER_RELAY_HOST"] = relay_host
        os.environ["EMAIL_SERVER_RELAY_USER"] = relay_user
        os.environ["EMAIL_SERVER_RELAY_PASS"] = relay_pass

    try:
        _update_env_file(env_path, env_updates)
    except Exception as e:
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f Domain set for session but failed to persist: {e}",
        )
        return

    relay_note = ""
    if len(relay_args) >= 3:
        relay_note = f"\nRelay: {fmt_code(relay_args[0], m)}"

    await host.wizard_send(
        recipient_id,
        f"\u2705 {fmt_bold('Email Server Configured', m)}\n\n"
        f"Domain: {fmt_bold(domain, m)}{relay_note}\n"
        f"Saved to: {fmt_code('~/.familiar/.env', m)}\n\n"
        f"{fmt_bold('Next steps:', m)}\n"
        f"  1. Ensure DNS records are set:\n"
        f"     MX: {domain} \u2192 mail.{domain}\n"
        f"     SPF: v=spf1 mx ~all\n"
        f"     DMARC: v=DMARC1; p=quarantine\n"
        f"  2. Test: {fmt_code('/connect email-server test', m)}",
    )


async def wizard_email_server_test(host: WizardHost, recipient_id: str):
    """Test email server port reachability and dependencies."""
    m = host.format_mode
    domain = os.environ.get("EMAIL_SERVER_DOMAIN", "")
    ck = "\u2705"
    cr = "\u274c"

    if not domain:
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f No email server domain configured.\n\n"
            f"Set one first: {fmt_code('/connect email-server mail.example.com', m)}",
        )
        return

    results = []

    # Check aiosmtpd — auto-install if missing
    try:
        import aiosmtpd  # noqa: F401

        results.append(f"  {ck} aiosmtpd library installed")
    except ImportError:
        from familiar.core.deps import (
            AIOSMTPD_PACKAGES,
            ensure_packages,
        )

        ok, _ = ensure_packages(AIOSMTPD_PACKAGES)
        if ok:
            results.append(f"  {ck} aiosmtpd library installed (auto)")
        else:
            results.append(f"  {cr} aiosmtpd library could not be installed")

    # Check ports
    mail_host = f"mail.{domain}" if not domain.startswith("mail.") else domain
    for port, label in [
        (25, "SMTP"),
        (587, "Submission"),
        (993, "IMAP"),
    ]:
        reachable = _check_tcp_port(mail_host, port)
        results.append(f"  {ck if reachable else cr} Port {port} ({label}) on {mail_host}")

    # Check relay if configured
    relay_host = os.environ.get("EMAIL_SERVER_RELAY_HOST", "")
    if relay_host:
        relay_ok = _check_tcp_port(relay_host, 587)
        results.append(f"  {ck if relay_ok else cr} Relay {relay_host}:587")

    await host.wizard_send(
        recipient_id,
        f"\U0001f4ec {fmt_bold(f'Email Server Test — {domain}', m)}\n\n" + "\n".join(results),
    )


# ── Menu selection dispatcher ────────────────────────────────────


async def handle_menu_selection(host: WizardHost, recipient_id: str, key: str) -> bool:
    """Handle button callbacks for self-hosted service wizards.

    Returns ``True`` if the key was recognised, ``False`` otherwise.
    """
    if key == "jellyfin_menu":
        await wizard_jellyfin(host, recipient_id, [])
        return True
    if key == "gitea_menu":
        await wizard_gitea(host, recipient_id, [])
        return True
    if key == "homeassistant_menu":
        await wizard_homeassistant(host, recipient_id, [])
        return True
    if key == "joplin_menu":
        await wizard_joplin(host, recipient_id, [])
        return True
    if key == "joplin_install":
        await _joplin_install(host, recipient_id)
        return True
    if key == "joplin_manual":
        m = host.format_mode
        await host.wizard_send(
            recipient_id,
            f"\U0001f4dd {fmt_bold('Manual Joplin Install', m)}\n\n"
            f"1. Download from: https://joplinapp.org/download/\n"
            f"2. Install and launch Joplin\n"
            f"3. Go to Options \u2192 Web Clipper\n"
            f"4. Enable the Web Clipper service\n"
            f"5. Copy the authorization token\n"
            f"6. Run: {fmt_code('/connect joplin <TOKEN>', m)}",
        )
        return True
    if key == "pihole_menu":
        await wizard_pihole(host, recipient_id, [])
        return True
    if key == "nextcloud_menu":
        await wizard_nextcloud(host, recipient_id, [])
        return True
    if key == "proton_services_menu":
        await wizard_proton_services(host, recipient_id, [])
        return True
    if key == "proton_vpn_connect":
        await wizard_proton_vpn_action(host, recipient_id, "connect")
        return True
    if key == "proton_vpn_disconnect":
        await wizard_proton_vpn_action(host, recipient_id, "disconnect")
        return True
    if key == "proton_vpn_status":
        await wizard_proton_vpn_action(host, recipient_id, "status")
        return True
    if key == "proton_vpn_install":
        from familiar.core.deps import (
            PROTONVPN_PACKAGES,
            ensure_packages_async,
        )

        await host.wizard_send(recipient_id, "Installing protonvpn-cli...")
        ok, failed = await ensure_packages_async(PROTONVPN_PACKAGES)
        if ok:
            await host.wizard_send(
                recipient_id,
                "\u2705 ProtonVPN CLI installed!",
            )
        else:
            await host.wizard_send(
                recipient_id,
                f"\u26a0\ufe0f Install failed: {', '.join(failed)}",
            )
        await wizard_proton_services(host, recipient_id, [])
        return True
    if key == "email_server_menu":
        await wizard_email_server(host, recipient_id, [])
        return True
    if key == "pihole_type_pihole":
        state = host._wizard_state.get(recipient_id)
        if state and state.get("type") == "pihole" and state.get("step") == "type":
            url = state["url"]
            token = state["token"]
            host._wizard_state.pop(recipient_id, None)
            await host.wizard_send(recipient_id, "Testing pihole connection...")
            await wizard_pihole_save(host, recipient_id, url, token, "pihole")
        return True
    if key == "pihole_type_adguard":
        state = host._wizard_state.get(recipient_id)
        if state and state.get("type") == "pihole" and state.get("step") == "type":
            url = state["url"]
            token = state["token"]
            host._wizard_state.pop(recipient_id, None)
            await host.wizard_send(recipient_id, "Testing adguard connection...")
            await wizard_pihole_save(host, recipient_id, url, token, "adguard")
        return True

    return False
