# 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,
    auto_provision_service,
    get_service_url,
    is_service_provisionable,
    is_service_provisioned,
)
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
    blurb = _service_doc_blurb("jellyfin", m)

    # Check if already provisioned via service manager
    if is_service_provisioned("jellyfin"):
        local_url = get_service_url("jellyfin") or "http://localhost:8096"
        host._wizard_state[recipient_id] = {
            "type": "jellyfin",
            "step": "token",
            "url": local_url,
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f3ac {fmt_bold('Jellyfin Setup', m)}\n\n"
            f"Jellyfin is running at {fmt_code(local_url, m)}.\n\n"
            f"Enter your {fmt_bold('API token', m)}:\n"
            f"Find it at: {fmt_code(local_url + '/web/#/dashboard/apikeys', m)}",
        )
        return

    # LAN auto-discovery
    from ._discovery import discover_single_service

    found = discover_single_service("jellyfin")
    if found:
        host._wizard_state[recipient_id] = {
            "type": "jellyfin",
            "step": "confirm_discovered",
            "discovered_url": found["url"],
        }
        name_hint = f" ({found['name']})" if found.get("name") else ""
        await host.wizard_send(
            recipient_id,
            f"\U0001f3ac {fmt_bold('Jellyfin Setup', m)}\n"
            f"{blurb}\n"
            f"I found Jellyfin at {fmt_code(found['url'], m)}{name_hint}.\n\n"
            f"Use this address? Type {fmt_bold('yes', m)} or enter a different URL:",
        )
        return

    # Offer auto-install if provisionable
    if is_service_provisionable("jellyfin"):
        host._wizard_state[recipient_id] = {
            "type": "jellyfin",
            "step": "choose",
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f3ac {fmt_bold('Jellyfin Setup', m)}\n"
            f"{blurb}\n"
            f"Jellyfin is a self-hosted media server for movies, "
            f"shows, and music.\n\n"
            f"{fmt_bold('Choose an option:', m)}\n"
            f"  {fmt_bold('1', m)} \u2014 Auto-install (sets up Jellyfin for you)\n"
            f"  {fmt_bold('2', m)} \u2014 Connect an existing Jellyfin server\n\n"
            f"Type {fmt_code('1', m)} or {fmt_code('2', m)}:",
        )
        return

    host._wizard_state[recipient_id] = {
        "type": "jellyfin",
        "step": "url",
    }
    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 == "choose":
        if text == "1":
            # Auto-provision Jellyfin
            ok = await auto_provision_service(host, recipient_id, "jellyfin")
            if ok:
                local_url = get_service_url("jellyfin") or "http://localhost:8096"
                host._wizard_state[recipient_id] = {
                    "type": "jellyfin",
                    "step": "token",
                    "url": local_url,
                }
                await host.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold('Jellyfin is running!', m)}\n\n"
                    f"  Web UI: {fmt_code(local_url, m)}\n\n"
                    f"Open the web UI, complete initial setup, then create an API key at:\n"
                    f"  Dashboard \u2192 API Keys \u2192 Create\n\n"
                    f"Enter your {fmt_bold('API token', m)}:",
                )
            else:
                host._wizard_state[recipient_id] = {"type": "jellyfin", "step": "choose"}
                await host.wizard_send(
                    recipient_id,
                    f"Type {fmt_code('1', m)} to retry or {fmt_code('2', m)} "
                    f"to connect an existing instance:",
                )
        elif text == "2":
            state["step"] = "url"
            await host.wizard_send(
                recipient_id,
                f"Enter your {fmt_bold('Jellyfin server URL', m)}:\n\n"
                f"{fmt_italic('Example: https://jellyfin.example.org', m)}",
            )
        else:
            await host.wizard_send(
                recipient_id,
                f"Please type {fmt_code('1', m)} for auto-install "
                f"or {fmt_code('2', m)} for existing instance:",
            )
        return

    if step == "confirm_discovered":
        if text.lower() in ("yes", "y"):
            state["url"] = state.pop("discovered_url")
        elif text.startswith(("http://", "https://")):
            state["url"] = text.rstrip("/")
        else:
            await host.wizard_send(
                recipient_id,
                f"Type {fmt_bold('yes', m)} to use the discovered URL, "
                f"or enter a different URL starting with http:// or https://:",
            )
            return
        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"\u2705 URL: {fmt_code(state['url'], m)}\n\n"
            f"\U0001f510 Enter your {fmt_bold('API Token', m)}:\n\n"
            f"Create one at: Jellyfin 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 ""
            ),
        )
        return

    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 as e:
                logger.debug("Error suppressed: %s", e)
        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, service_key="jellyfin")
    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
    blurb = _service_doc_blurb("gitea", m)

    # Check if already provisioned
    if is_service_provisioned("gitea"):
        local_url = get_service_url("gitea") or "http://localhost:3000"
        host._wizard_state[recipient_id] = {
            "type": "gitea",
            "step": "token",
            "url": local_url,
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f419 {fmt_bold('Forgejo / Gitea Setup', m)}\n\n"
            f"Forgejo is running at {fmt_code(local_url, m)}.\n\n"
            f"Enter your {fmt_bold('access token', m)}:\n"
            f"Create one at: {fmt_code(local_url + '/user/settings/applications', m)}",
        )
        return

    from ._discovery import discover_single_service

    found = discover_single_service("gitea")
    if found:
        host._wizard_state[recipient_id] = {
            "type": "gitea",
            "step": "confirm_discovered",
            "discovered_url": found["url"],
        }
        name_hint = f" ({found['name']})" if found.get("name") else ""
        await host.wizard_send(
            recipient_id,
            f"\U0001f419 {fmt_bold('Forgejo / Gitea Setup', m)}\n"
            f"{blurb}\n"
            f"I found Gitea/Forgejo at {fmt_code(found['url'], m)}{name_hint}.\n\n"
            f"Use this address? Type {fmt_bold('yes', m)} or enter a different URL:",
        )
        return

    # Offer auto-install
    if is_service_provisionable("gitea"):
        host._wizard_state[recipient_id] = {
            "type": "gitea",
            "step": "choose",
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f419 {fmt_bold('Forgejo / Gitea Setup', m)}\n"
            f"{blurb}\n"
            f"Forgejo is a self-hosted Git forge for repos, issues, and PRs.\n\n"
            f"{fmt_bold('Choose an option:', m)}\n"
            f"  {fmt_bold('1', m)} \u2014 Auto-install (sets up Forgejo for you)\n"
            f"  {fmt_bold('2', m)} \u2014 Connect an existing Gitea/Forgejo instance\n\n"
            f"Type {fmt_code('1', m)} or {fmt_code('2', m)}:",
        )
        return

    host._wizard_state[recipient_id] = {
        "type": "gitea",
        "step": "url",
    }
    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 == "choose":
        if text == "1":
            ok = await auto_provision_service(host, recipient_id, "gitea")
            if ok:
                local_url = get_service_url("gitea") or "http://localhost:3000"
                host._wizard_state[recipient_id] = {
                    "type": "gitea",
                    "step": "token",
                    "url": local_url,
                }
                await host.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold('Forgejo is running!', m)}\n\n"
                    f"  Web UI: {fmt_code(local_url, m)}\n\n"
                    f"Open the web UI, create your admin account, then generate a token at:\n"
                    f"  Settings \u2192 Applications \u2192 Generate Token\n\n"
                    f"Enter your {fmt_bold('access token', m)}:",
                )
            else:
                host._wizard_state[recipient_id] = {"type": "gitea", "step": "choose"}
                await host.wizard_send(
                    recipient_id,
                    f"Type {fmt_code('1', m)} to retry or {fmt_code('2', m)} "
                    f"to connect an existing instance:",
                )
        elif text == "2":
            state["step"] = "url"
            await host.wizard_send(
                recipient_id,
                f"Enter your {fmt_bold('instance URL', m)}:\n\n"
                f"{fmt_italic('Example: https://forge.example.org', m)}",
            )
        else:
            await host.wizard_send(
                recipient_id,
                f"Please type {fmt_code('1', m)} for auto-install "
                f"or {fmt_code('2', m)} for existing instance:",
            )
        return

    if step == "confirm_discovered":
        if text.lower() in ("yes", "y"):
            state["url"] = state.pop("discovered_url")
        elif text.startswith(("http://", "https://")):
            state["url"] = text.rstrip("/")
        else:
            await host.wizard_send(
                recipient_id,
                f"Type {fmt_bold('yes', m)} to use the discovered URL, "
                f"or enter a different URL starting with http:// or https://:",
            )
            return
        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"\u2705 URL: {fmt_code(state['url'], m)}\n\n"
            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 ""
            ),
        )
        return

    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 as e:
                logger.debug("Error suppressed: %s", e)
        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, service_key="gitea")
    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
    blurb = _service_doc_blurb("homeassistant", m)

    from ._discovery import discover_single_service

    found = discover_single_service("homeassistant")
    if found:
        host._wizard_state[recipient_id] = {
            "type": "homeassistant",
            "step": "confirm_discovered",
            "discovered_url": found["url"],
        }
        name_hint = f" ({found['name']})" if found.get("name") else ""
        await host.wizard_send(
            recipient_id,
            f"\U0001f3e0 {fmt_bold('Home Assistant Setup', m)}\n"
            f"{blurb}\n"
            f"I found Home Assistant at {fmt_code(found['url'], m)}{name_hint}.\n\n"
            f"Use this address? Type {fmt_bold('yes', m)} or enter a different URL:",
        )
        return

    host._wizard_state[recipient_id] = {
        "type": "homeassistant",
        "step": "url",
    }
    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 == "confirm_discovered":
        if text.lower() in ("yes", "y"):
            state["url"] = state.pop("discovered_url")
        elif text.startswith(("http://", "https://")):
            state["url"] = text.rstrip("/")
        else:
            await host.wizard_send(
                recipient_id,
                f"Type {fmt_bold('yes', m)} to use the discovered URL, "
                f"or enter a different URL starting with http:// or https://:",
            )
            return
        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"\u2705 URL: {fmt_code(state['url'], m)}\n\n"
            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 ""
            ),
        )
        return

    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 as e:
                logger.debug("Error suppressed: %s", e)
        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, service_key="homeassistant")
    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 as e:
        logger.debug("Error suppressed: %s", e)
    # 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 as e:
        logger.debug("Error suppressed: %s", e)
    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
    blurb = _service_doc_blurb("joplin", m)

    # Check if already provisioned
    if is_service_provisioned("joplin"):
        local_url = get_service_url("joplin") or "http://localhost:22300"
        host._wizard_state[recipient_id] = {
            "type": "joplin",
            "step": "token",
            "url": local_url,
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f4dd {fmt_bold('Joplin Setup', m)}\n\n"
            f"Joplin Server is running at {fmt_code(local_url, m)}.\n\n"
            f"\U0001f510 Enter your {fmt_bold('Joplin token', m)}:\n"
            f"Find it at: Options \u2192 Web Clipper \u2192 Authorization token",
        )
        return

    # LAN auto-discovery for Joplin Server
    from ._discovery import discover_single_service

    found = discover_single_service("joplin")
    discovered_hint = ""
    state_extra = {}
    if found:
        state_extra["discovered_url"] = found["url"]
        discovered_hint = (
            f"\n\nI found Joplin Server at {fmt_code(found['url'], m)}. "
            f"It will be used automatically after you enter your token."
        )

    # Offer auto-install
    if not found and is_service_provisionable("joplin"):
        host._wizard_state[recipient_id] = {
            "type": "joplin",
            "step": "choose",
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f4dd {fmt_bold('Joplin Setup', m)}\n"
            f"{blurb}\n"
            f"Joplin Server syncs notes across devices with "
            f"end-to-end encryption.\n\n"
            f"{fmt_bold('Choose an option:', m)}\n"
            f"  {fmt_bold('1', m)} \u2014 Auto-install (sets up Joplin Server for you)\n"
            f"  {fmt_bold('2', m)} \u2014 Connect an existing Joplin instance\n\n"
            f"Type {fmt_code('1', m)} or {fmt_code('2', m)}:",
        )
        return

    host._wizard_state[recipient_id] = {
        "type": "joplin",
        "step": "token",
        **state_extra,
    }
    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"
        f"{discovered_hint}"
        + (
            "\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 == "choose":
        if text == "1":
            ok = await auto_provision_service(host, recipient_id, "joplin")
            if ok:
                local_url = get_service_url("joplin") or "http://localhost:22300"
                host._wizard_state[recipient_id] = {
                    "type": "joplin",
                    "step": "token",
                    "url": local_url,
                }
                await host.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold('Joplin Server is running!', m)}\n\n"
                    f"  Web UI: {fmt_code(local_url, m)}\n\n"
                    f"Enter your {fmt_bold('Joplin token', m)}:\n"
                    f"Find it at: Options \u2192 Web Clipper \u2192 Authorization token",
                )
            else:
                host._wizard_state[recipient_id] = {"type": "joplin", "step": "choose"}
                await host.wizard_send(
                    recipient_id,
                    f"Type {fmt_code('1', m)} to retry or {fmt_code('2', m)} "
                    f"to connect an existing instance:",
                )
        elif text == "2":
            state["step"] = "token"
            await host.wizard_send(
                recipient_id,
                f"\U0001f510 Enter your {fmt_bold('Joplin Web Clipper token', m)}:\n\n"
                f"Find it at: Options \u2192 Web Clipper \u2192 Authorization token",
            )
        else:
            await host.wizard_send(
                recipient_id,
                f"Please type {fmt_code('1', m)} for auto-install "
                f"or {fmt_code('2', m)} for existing instance:",
            )
        return

    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 as e:
                logger.debug("Error suppressed: %s", e)
        state["token"] = text

        # If LAN discovery found Joplin Server, pre-fill the URL
        if "discovered_url" in state:
            state["step"] = "confirm_discovered"
            await host.wizard_send(
                recipient_id,
                f"Use discovered URL {fmt_code(state['discovered_url'], m)}?\n\n"
                f"Type {fmt_bold('yes', m)} or enter a different URL:\n"
                f"Default (local desktop): {fmt_code('http://localhost:41184', m)}",
            )
            return

        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 == "confirm_discovered":
        if text.lower() in ("yes", "y"):
            url = state.pop("discovered_url")
        elif text.startswith(("http://", "https://")):
            url = text.rstrip("/")
            state.pop("discovered_url", None)
        elif text.lower() in ("default", "skip"):
            url = "http://localhost:41184"
            state.pop("discovered_url", None)
        else:
            await host.wizard_send(
                recipient_id,
                f"Type {fmt_bold('yes', m)} to use the discovered URL, "
                f"enter a different URL, or type {fmt_bold('default', m)}:",
            )
            return
        token = state["token"]
        host._wizard_state.pop(recipient_id, None)
        await wizard_joplin_save(host, recipient_id, token, url)

    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, service_key="joplin")
    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
    blurb = _service_doc_blurb("pihole", m)

    if is_service_provisioned("pihole"):
        local_url = get_service_url("pihole") or "http://localhost:8053"
        host._wizard_state[recipient_id] = {
            "type": "pihole",
            "step": "token",
            "url": local_url,
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f6e1\ufe0f {fmt_bold('Pi-hole Setup', m)}\n\n"
            f"Pi-hole is running at {fmt_code(local_url, m)}.\n\n"
            f"Enter your {fmt_bold('API token', m)}:\n"
            f"Find it at: {fmt_code(local_url + '/admin/settings.php?tab=api', m)}",
        )
        return

    from ._discovery import discover_single_service

    found = discover_single_service("pihole")
    if found:
        host._wizard_state[recipient_id] = {
            "type": "pihole",
            "step": "confirm_discovered",
            "discovered_url": found["url"],
        }
        name_hint = f" ({found['name']})" if found.get("name") else ""
        await host.wizard_send(
            recipient_id,
            f"\U0001f6e1\ufe0f {fmt_bold('Pi-hole / AdGuard Setup', m)}\n"
            f"{blurb}\n"
            f"I found Pi-hole at {fmt_code(found['url'], m)}{name_hint}.\n\n"
            f"Use this address? Type {fmt_bold('yes', m)} or enter a different URL:",
        )
        return

    if is_service_provisionable("pihole"):
        host._wizard_state[recipient_id] = {
            "type": "pihole",
            "step": "choose",
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f6e1\ufe0f {fmt_bold('Pi-hole / AdGuard Setup', m)}\n"
            f"{blurb}\n"
            f"Pi-hole blocks ads and trackers at the DNS level.\n\n"
            f"{fmt_bold('Choose an option:', m)}\n"
            f"  {fmt_bold('1', m)} \u2014 Auto-install (sets up Pi-hole for you)\n"
            f"  {fmt_bold('2', m)} \u2014 Connect an existing Pi-hole or AdGuard Home\n\n"
            f"Type {fmt_code('1', m)} or {fmt_code('2', m)}:",
        )
        return

    host._wizard_state[recipient_id] = {
        "type": "pihole",
        "step": "url",
    }
    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 == "choose":
        if text == "1":
            ok = await auto_provision_service(host, recipient_id, "pihole")
            if ok:
                local_url = get_service_url("pihole") or "http://localhost:8053"
                host._wizard_state[recipient_id] = {
                    "type": "pihole",
                    "step": "token",
                    "url": local_url,
                }
                await host.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold('Pi-hole is running!', m)}\n\n"
                    f"  Admin: {fmt_code(local_url + '/admin', m)}\n\n"
                    f"Enter your {fmt_bold('API token', m)}:\n"
                    f"Find it at: Settings \u2192 API",
                )
            else:
                host._wizard_state[recipient_id] = {"type": "pihole", "step": "choose"}
                await host.wizard_send(
                    recipient_id,
                    f"Type {fmt_code('1', m)} to retry or {fmt_code('2', m)} "
                    f"to connect an existing instance:",
                )
        elif text == "2":
            state["step"] = "url"
            await host.wizard_send(
                recipient_id,
                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)}",
            )
        else:
            await host.wizard_send(
                recipient_id,
                f"Please type {fmt_code('1', m)} for auto-install "
                f"or {fmt_code('2', m)} for existing instance:",
            )
        return

    if step == "confirm_discovered":
        if text.lower() in ("yes", "y"):
            state["url"] = state.pop("discovered_url")
        elif text.startswith(("http://", "https://")):
            state["url"] = text.rstrip("/")
        else:
            await host.wizard_send(
                recipient_id,
                f"Type {fmt_bold('yes', m)} to use the discovered URL, "
                f"or enter a different URL starting with http:// or https://:",
            )
            return
        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"\u2705 URL: {fmt_code(state['url'], m)}\n\n"
            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 ""
            ),
        )
        return

    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 as e:
                logger.debug("Error suppressed: %s", e)
        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, service_key="pihole")
    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
    blurb = _service_doc_blurb("nextcloud", m)

    if is_service_provisioned("nextcloud"):
        local_url = get_service_url("nextcloud") or "http://localhost:8080"
        host._wizard_state[recipient_id] = {
            "type": "nextcloud",
            "step": "url",
            "url": local_url,
        }
        # Nextcloud needs username + app password, skip URL step
        host._wizard_state[recipient_id]["step"] = "user"
        host._wizard_state[recipient_id]["url"] = local_url
        await host.wizard_send(
            recipient_id,
            f"\u2601\ufe0f {fmt_bold('Nextcloud Setup', m)}\n\n"
            f"Nextcloud is running at {fmt_code(local_url, m)}.\n\n"
            f"Enter your {fmt_bold('Nextcloud username', m)}:",
        )
        return

    from ._discovery import discover_single_service

    found = discover_single_service("nextcloud")
    if found:
        host._wizard_state[recipient_id] = {
            "type": "nextcloud",
            "step": "confirm_discovered",
            "discovered_url": found["url"],
        }
        name_hint = f" ({found['name']})" if found.get("name") else ""
        await host.wizard_send(
            recipient_id,
            f"\u2601\ufe0f {fmt_bold('Nextcloud Setup', m)}\n"
            f"{blurb}\n"
            f"I found Nextcloud at {fmt_code(found['url'], m)}{name_hint}.\n\n"
            f"Use this address? Type {fmt_bold('yes', m)} or enter a different URL:",
        )
        return

    if is_service_provisionable("nextcloud"):
        host._wizard_state[recipient_id] = {
            "type": "nextcloud",
            "step": "choose",
        }
        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\n"
            f"{fmt_bold('Choose an option:', m)}\n"
            f"  {fmt_bold('1', m)} \u2014 Auto-install (sets up Nextcloud for you)\n"
            f"  {fmt_bold('2', m)} \u2014 Connect an existing Nextcloud instance\n\n"
            f"Type {fmt_code('1', m)} or {fmt_code('2', m)}:",
        )
        return

    host._wizard_state[recipient_id] = {
        "type": "nextcloud",
        "step": "url",
    }
    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 == "choose":
        if text == "1":
            ok = await auto_provision_service(host, recipient_id, "nextcloud")
            if ok:
                local_url = get_service_url("nextcloud") or "http://localhost:8080"
                host._wizard_state[recipient_id] = {
                    "type": "nextcloud",
                    "step": "user",
                    "url": local_url,
                }
                await host.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold('Nextcloud is running!', m)}\n\n"
                    f"  Web UI: {fmt_code(local_url, m)}\n\n"
                    f"Open the web UI, create your admin account, then:\n"
                    f"  Settings \u2192 Security \u2192 Create new app password\n\n"
                    f"Enter your {fmt_bold('Nextcloud username', m)}:",
                )
            else:
                host._wizard_state[recipient_id] = {"type": "nextcloud", "step": "choose"}
                await host.wizard_send(
                    recipient_id,
                    f"Type {fmt_code('1', m)} to retry or {fmt_code('2', m)} "
                    f"to connect an existing instance:",
                )
        elif text == "2":
            state["step"] = "url"
            await host.wizard_send(
                recipient_id,
                f"Enter your {fmt_bold('Nextcloud URL', m)}:\n\n"
                f"{fmt_italic('Example: https://cloud.example.org', m)}",
            )
        else:
            await host.wizard_send(
                recipient_id,
                f"Please type {fmt_code('1', m)} for auto-install "
                f"or {fmt_code('2', m)} for existing instance:",
            )
        return

    if step == "confirm_discovered":
        if text.lower() in ("yes", "y"):
            state["url"] = state.pop("discovered_url")
        elif text.startswith(("http://", "https://")):
            state["url"] = text.rstrip("/")
        else:
            await host.wizard_send(
                recipient_id,
                f"Type {fmt_bold('yes', m)} to use the discovered URL, "
                f"or enter a different URL starting with http:// or https://:",
            )
            return
        state["step"] = "user"
        await host.wizard_send(
            recipient_id,
            f"\u2705 URL: {fmt_code(state['url'], m)}\n\n"
            f"Enter your {fmt_bold('Nextcloud username', m)}:",
        )
        return

    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 as e:
                logger.debug("Error suppressed: %s", e)
        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, service_key="nextcloud")
    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, service_key="nextcloud")
    except Exception as e:
        logger.debug("Error suppressed: %s", e)
    # 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 (Stalwart Mail) setup wizard.

    Auto-discovers the local Stalwart container if running, or guides
    manual IMAP/SMTP configuration for any mail server.
    """
    m = host.format_mode
    sub = args[0].lower() if args else ""

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

    # Manual IMAP/SMTP setup: /connect email-server <email> <password> [imap-host] [smtp-host]
    if sub and "@" in sub:
        email_addr = sub
        password = args[1] if len(args) > 1 else ""
        imap_host = args[2] if len(args) > 2 else "localhost"
        smtp_host = args[3] if len(args) > 3 else "localhost"
        await wizard_email_server_save(
            host, recipient_id, email_addr, password, imap_host, smtp_host
        )
        return

    # Legacy domain-only setup for backward compatibility
    if sub and "." in sub and "@" not in sub:
        domain = sub
        relay_args = args[1:] if len(args) > 1 else []
        await wizard_email_server_save_legacy(host, recipient_id, domain, relay_args)
        return

    # Show status or detect local Stalwart
    email_addr = os.environ.get("EMAIL_ADDRESS", "")
    if email_addr:
        imap = os.environ.get("EMAIL_IMAP_SERVER", "")
        smtp = os.environ.get("EMAIL_SMTP_SERVER", "")
        await host.wizard_send(
            recipient_id,
            f"\U0001f4ec {fmt_bold('Email Server', m)}\n\n"
            f"  \u2705 Email: {fmt_bold(email_addr, m)}\n"
            f"  \u2705 IMAP: {fmt_code(imap or 'default', m)}\n"
            f"  \u2705 SMTP: {fmt_code(smtp or 'default', m)}\n\n"
            f"{fmt_bold('Commands:', m)}\n"
            f"  {fmt_code('/connect email-server test', m)} \u2014 test connection\n"
            f"  {fmt_code('/connect email-server you@domain.com password', m)} \u2014 reconfigure",
        )
        return

    # Check if Stalwart is running locally
    stalwart_running = _check_tcp_port("127.0.0.1", 8010)
    blurb = _service_doc_blurb("email_server", m)

    if stalwart_running:
        await host.wizard_send(
            recipient_id,
            f"\U0001f4ec {fmt_bold('Email Server Setup', m)}\n"
            f"{blurb}\n"
            f"\u2705 {fmt_bold('Stalwart Mail detected', m)} on localhost:8010\n\n"
            f"{fmt_bold('Steps:', m)}\n"
            f"  1. Open admin UI: {fmt_code('http://localhost:8010', m)}\n"
            f"  2. Log in (check Server \u2192 Logs for auto-generated password)\n"
            f"  3. Create your mailbox (e.g. you@yourdomain.com)\n"
            f"  4. Run: {fmt_code('/connect email-server you@domain.com password', m)}\n\n"
            f"Familiar will auto-connect via localhost IMAP/SMTP ports.",
        )
    else:
        await host.wizard_send(
            recipient_id,
            f"\U0001f4ec {fmt_bold('Email Server Setup', m)}\n"
            f"{blurb}\n"
            f"No email server detected. Options:\n\n"
            f"{fmt_bold('Option 1 — Self-hosted (recommended):', m)}\n"
            f"  Go to Server tab \u2192 Provision Stalwart Mail\n"
            f"  Then run this wizard again to connect.\n\n"
            f"{fmt_bold('Option 2 — External provider:', m)}\n"
            f"  {fmt_code('/connect email-server you@gmail.com app-password imap.gmail.com smtp.gmail.com', m)}\n\n"
            f"{fmt_bold('Option 3 — Gmail API:', m)}\n"
            f"  {fmt_code('/connect google', m)} (OAuth, no app password needed)",
        )


async def wizard_email_server_save(
    host: WizardHost,
    recipient_id: str,
    email_addr: str,
    password: str,
    imap_host: str = "localhost",
    smtp_host: str = "localhost",
):
    """Save email server IMAP/SMTP credentials.

    For local Stalwart, uses localhost ports (9930 IMAP, 5870 SMTP).
    For external providers, uses standard ports (993/587).
    """
    m = host.format_mode
    env_path = Path.home() / ".familiar" / ".env"

    # Detect if pointing at local Stalwart
    is_local = imap_host in ("localhost", "127.0.0.1")
    imap_port = "9930" if is_local else "993"
    smtp_port = "5870" if is_local else "587"

    # Encrypt password before storing
    try:
        from familiar.core.credential_store import encrypt_password

        pass_stored = encrypt_password(password)
    except ImportError:
        pass_stored = password

    env_updates = {
        "EMAIL_ADDRESS": email_addr,
        "EMAIL_PASSWORD": pass_stored,
        "EMAIL_IMAP_SERVER": imap_host,
        "EMAIL_SMTP_SERVER": smtp_host,
        "EMAIL_IMAP_PORT": imap_port,
        "EMAIL_SMTP_PORT": smtp_port,
    }

    for k, v in env_updates.items():
        os.environ[k] = v
    # Store unencrypted password in runtime env for immediate use
    os.environ["EMAIL_PASSWORD"] = password

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

    server_note = "Stalwart (local)" if is_local else f"{imap_host}"
    await host.wizard_send(
        recipient_id,
        f"\u2705 {fmt_bold('Email Configured', m)}\n\n"
        f"  Email: {fmt_bold(email_addr, m)}\n"
        f"  IMAP: {fmt_code(imap_host + ':' + imap_port, m)}\n"
        f"  SMTP: {fmt_code(smtp_host + ':' + smtp_port, m)}\n"
        f"  Server: {server_note}\n"
        f"  Saved to: {fmt_code('~/.familiar/.env', m)}\n\n"
        f"Test: {fmt_code('/connect email-server test', m)}",
    )


async def wizard_email_server_save_legacy(
    host: WizardHost,
    recipient_id: str,
    domain: str,
    relay_args: list,
):
    """Legacy save: domain-based email server config (backward compat)."""
    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]
        try:
            from familiar.core.credential_store import encrypt_password

            relay_pass_stored = encrypt_password(relay_pass)
        except ImportError:
            relay_pass_stored = relay_pass
        env_updates["EMAIL_SERVER_RELAY_HOST"] = relay_host
        env_updates["EMAIL_SERVER_RELAY_USER"] = relay_user
        env_updates["EMAIL_SERVER_RELAY_PASS"] = relay_pass_stored
        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, service_key="email_server")
    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 connectivity — checks IMAP/SMTP ports and Stalwart admin."""
    m = host.format_mode
    ck = "\u2705"
    cr = "\u274c"

    email_addr = os.environ.get("EMAIL_ADDRESS", "")
    domain = os.environ.get("EMAIL_SERVER_DOMAIN", "")

    if not email_addr and not domain:
        await host.wizard_send(
            recipient_id,
            f"\u26a0\ufe0f No email configured.\n\n"
            f"Run: {fmt_code('/connect email-server you@domain.com password', m)}",
        )
        return

    results = []

    # Check Stalwart admin UI
    stalwart_up = _check_tcp_port("127.0.0.1", 8010)
    results.append(f"  {ck if stalwart_up else cr} Stalwart Admin UI (localhost:8010)")

    # Check local IMAP/SMTP ports (Stalwart mapped ports)
    imap_host = os.environ.get("EMAIL_IMAP_SERVER", "localhost")
    smtp_host = os.environ.get("EMAIL_SMTP_SERVER", "localhost")
    imap_port = int(
        os.environ.get(
            "EMAIL_IMAP_PORT", "9930" if imap_host in ("localhost", "127.0.0.1") else "993"
        )
    )
    smtp_port = int(
        os.environ.get(
            "EMAIL_SMTP_PORT", "5870" if smtp_host in ("localhost", "127.0.0.1") else "587"
        )
    )

    imap_ok = _check_tcp_port(imap_host, imap_port)
    smtp_ok = _check_tcp_port(smtp_host, smtp_port)
    results.append(f"  {ck if imap_ok else cr} IMAP ({imap_host}:{imap_port})")
    results.append(f"  {ck if smtp_ok else cr} SMTP ({smtp_host}:{smtp_port})")

    # Try actual IMAP login if credentials are set
    if email_addr and os.environ.get("EMAIL_PASSWORD") and imap_ok:
        try:
            import imaplib

            if imap_port == 993 or imap_port == 9930:
                conn = imaplib.IMAP4_SSL(imap_host, imap_port)
            else:
                conn = imaplib.IMAP4(imap_host, imap_port)
            conn.login(email_addr, os.environ["EMAIL_PASSWORD"])
            conn.logout()
            results.append(f"  {ck} IMAP login as {email_addr}")
        except Exception as e:
            results.append(f"  {cr} IMAP login failed: {str(e)[:60]}")

    # Legacy domain check
    if domain and not email_addr:
        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}")

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


# ── Self-hosted: Mealie ──────────────────────────────────────────


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

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

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

    has_url = os.environ.get("MEALIE_URL", "")
    has_token = bool(os.environ.get("MEALIE_TOKEN"))

    if has_url and has_token:
        await host.wizard_send(
            recipient_id,
            f"\U0001f374 {fmt_bold('Mealie 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 mealie test', m)} \u2014 test connection\n",
        )
        return

    blurb = _service_doc_blurb("mealie", m)
    if host.supports_buttons:
        await start_mealie_wizard(host, recipient_id)
    else:
        await host.wizard_send(
            recipient_id,
            f"\U0001f374 {fmt_bold('Connect Mealie', m)}\n"
            f"{blurb}\n"
            f"{fmt_bold('Setup:', m)}\n"
            f"1. Open Mealie \u2192 User Settings \u2192 API Tokens\n"
            f"2. Create a new token\n"
            f"3. Copy the URL and token\n\n"
            f"{fmt_bold('One-shot setup:', m)}\n"
            f"  {fmt_code('/connect mealie <URL> <TOKEN>', m)}\n\n"
            f"{fmt_bold('Example:', m)}\n"
            f"  {fmt_code('/connect mealie http://mealie.local:9000 eyJ...', m)}",
        )
        await start_mealie_wizard(host, recipient_id)


async def start_mealie_wizard(host: WizardHost, recipient_id: str):
    host._ensure_wizard_state()
    m = host.format_mode
    blurb = _service_doc_blurb("mealie", m)

    if is_service_provisioned("mealie"):
        local_url = get_service_url("mealie") or "http://localhost:9925"
        host._wizard_state[recipient_id] = {
            "type": "mealie",
            "step": "token",
            "url": local_url,
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f374 {fmt_bold('Mealie Setup', m)}\n\n"
            f"Mealie is running at {fmt_code(local_url, m)}.\n\n"
            f"Enter your {fmt_bold('API token', m)}:\n"
            f"Find it at: {fmt_code(local_url, m)} \u2192 User \u2192 API Tokens",
        )
        return

    from ._discovery import discover_single_service

    found = discover_single_service("mealie")
    if found:
        host._wizard_state[recipient_id] = {
            "type": "mealie",
            "step": "confirm_discovered",
            "discovered_url": found["url"],
        }
        name_hint = f" ({found['name']})" if found.get("name") else ""
        await host.wizard_send(
            recipient_id,
            f"\U0001f374 {fmt_bold('Mealie Setup', m)}\n"
            f"{blurb}\n"
            f"I found Mealie at {fmt_code(found['url'], m)}{name_hint}.\n\n"
            f"Use this address? Type {fmt_bold('yes', m)} or enter a different URL:",
        )
        return

    if is_service_provisionable("mealie"):
        host._wizard_state[recipient_id] = {
            "type": "mealie",
            "step": "choose",
        }
        await host.wizard_send(
            recipient_id,
            f"\U0001f374 {fmt_bold('Mealie Setup', m)}\n"
            f"{blurb}\n"
            f"Mealie is a recipe manager and meal planner.\n\n"
            f"{fmt_bold('Choose an option:', m)}\n"
            f"  {fmt_bold('1', m)} \u2014 Auto-install (sets up Mealie for you)\n"
            f"  {fmt_bold('2', m)} \u2014 Connect an existing Mealie instance\n\n"
            f"Type {fmt_code('1', m)} or {fmt_code('2', m)}:",
        )
        return

    host._wizard_state[recipient_id] = {
        "type": "mealie",
        "step": "url",
    }
    await host.wizard_send(
        recipient_id,
        f"\U0001f374 {fmt_bold('Mealie Setup', m)}\n"
        f"{blurb}\n"
        f"Enter your {fmt_bold('Mealie URL', m)}:\n\n"
        f"{fmt_italic('Example: http://mealie.local:9000', m)}",
    )


async def mealie_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 == "choose":
        if text == "1":
            ok = await auto_provision_service(host, recipient_id, "mealie")
            if ok:
                local_url = get_service_url("mealie") or "http://localhost:9925"
                host._wizard_state[recipient_id] = {
                    "type": "mealie",
                    "step": "token",
                    "url": local_url,
                }
                await host.wizard_send(
                    recipient_id,
                    f"\u2705 {fmt_bold('Mealie is running!', m)}\n\n"
                    f"  Web UI: {fmt_code(local_url, m)}\n\n"
                    f"Open the web UI, create your account, then generate a token at:\n"
                    f"  User \u2192 API Tokens \u2192 Create\n\n"
                    f"Enter your {fmt_bold('API token', m)}:",
                )
            else:
                host._wizard_state[recipient_id] = {"type": "mealie", "step": "choose"}
                await host.wizard_send(
                    recipient_id,
                    f"Type {fmt_code('1', m)} to retry or {fmt_code('2', m)} "
                    f"to connect an existing instance:",
                )
        elif text == "2":
            state["step"] = "url"
            await host.wizard_send(
                recipient_id,
                f"Enter your {fmt_bold('Mealie URL', m)}:\n\n"
                f"{fmt_italic('Example: http://mealie.local:9000', m)}",
            )
        else:
            await host.wizard_send(
                recipient_id,
                f"Please type {fmt_code('1', m)} for auto-install "
                f"or {fmt_code('2', m)} for existing instance:",
            )
        return

    if step == "confirm_discovered":
        if text.lower() in ("yes", "y"):
            state["url"] = state.pop("discovered_url")
        elif text.startswith(("http://", "https://")):
            state["url"] = text.rstrip("/")
        else:
            await host.wizard_send(
                recipient_id,
                f"Type {fmt_bold('yes', m)} to use the discovered URL, "
                f"or enter a different URL starting with http:// or https://:",
            )
            return
        state["step"] = "token"
        warning = ""
        if not host.supports_message_deletion:
            warning = f"\n\n\u26a0\ufe0f {fmt_italic('This channel cannot delete messages.', m)}"
        await host.wizard_send(
            recipient_id,
            f"\u2705 URL saved: {fmt_code(state['url'], m)}\n\n"
            f"Enter your Mealie {fmt_bold('API Token', m)}:\n\n"
            f"Find it in: User Settings \u2192 API Tokens"
            f"{warning}",
        )
        return

    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:
            warning = f"\n\n\u26a0\ufe0f {fmt_italic('This channel cannot delete messages.', m)}"
        await host.wizard_send(
            recipient_id,
            f"\u2705 URL saved: {fmt_code(text, m)}\n\n"
            f"Enter your Mealie {fmt_bold('API Token', m)}:\n\n"
            f"Find it in: User Settings \u2192 API Tokens"
            f"{warning}",
        )

    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 as e:
                logger.debug("Error suppressed: %s", e)
        url = state.get("url", "")
        host._wizard_state.pop(recipient_id, None)
        await wizard_mealie_save(host, recipient_id, url, text)


async def wizard_mealie_save(host: WizardHost, recipient_id: str, url: str, token: str):
    env_vars = {"MEALIE_URL": url, "MEALIE_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, service_key="mealie")
    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 Mealie connection...")
    await wizard_mealie_test(host, recipient_id)


async def wizard_mealie_test(host: WizardHost, recipient_id: str):
    m = host.format_mode
    url = os.environ.get("MEALIE_URL", "")
    token = os.environ.get("MEALIE_TOKEN", "")
    if not url or not token:
        await host.wizard_send(
            recipient_id,
            f"\u274c Mealie not configured.\nRun: {fmt_code('/connect mealie', m)}",
        )
        return
    ok, msg = _test_http_service(
        f"{url}/api/recipes",
        headers={"Authorization": f"Bearer {token}"},
        params={"page": "1", "perPage": "1"},
    )
    if ok:
        await host.wizard_send(
            recipient_id,
            f"\u2705 {fmt_bold('Mealie connected!', m)}\n"
            f"  URL: {fmt_code(url, m)}\n  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 that Mealie is running at {fmt_code(url, m)}.",
        )


# ── 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 == "mealie_menu":
        await wizard_mealie(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
