"""Table-driven config migration registry.

Each step is ``_migrate_to_N(results, quiet)``; the version gate and strict ascending order live
in :func:`run_migrations`. Every write goes through ``hermes_cli.config._persist_migration`` so a
step may only persist values that differ from the schema default (plus removals/renames).
"""

from __future__ import annotations

import copy
import functools
import logging
import re
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import urlparse

logger = logging.getLogger(__name__)

#: Auto-migration support floor. Configs whose on-disk ``_config_version`` is below this are NOT
#: auto-migrated (v12 predates ~two years of releases; carrying the sub-v12 steps and the env
#: bridges they consumed forever is not worth it). Below-floor configs are left byte-for-byte
#: untouched — the process continues with defaults deep-merged at read time, matching the
#: non-fatal posture for unparseable configs — and a message tells the user how to proceed.
SUPPORT_FLOOR_VERSION = 12


def support_floor_message() -> str:
    """Human-facing explanation shown when a config is below the floor."""
    from hermes_constants import display_hermes_home

    return (
        f"This config predates version {SUPPORT_FLOOR_VERSION} (~2 years old) "
        "and can no longer be auto-migrated. Back up "
        f"{display_hermes_home()}/config.yaml and run `hermes setup` to "
        f"regenerate, or manually set _config_version: {SUPPORT_FLOOR_VERSION} "
        "after reviewing the changelog.")


def _cfg():
    """Return the live ``hermes_cli.config`` module (lazy, cycle-free, monkeypatch-friendly)."""
    from hermes_cli import config

    return config


def read_raw_config():
    return _cfg().read_raw_config()


def _persist_migration(config):
    _cfg()._persist_migration(config)


def _dict_at(config: Dict[str, Any], key: str) -> Dict[str, Any]:
    """``config[key]`` when it is a mapping (same object, so writes alias), else a fresh ``{}``."""
    value = config.get(key)
    return value if isinstance(value, dict) else {}


def _commit(
    config: Dict[str, Any],
    results: Dict[str, Any],
    quiet: bool,
    added: Optional[str],
    message: Optional[str]) -> None:
    """Persist *config*, record *added* under ``config_added`` and print *message* unless quiet."""
    _persist_migration(config)
    if added:
        results["config_added"].append(added)
    if message and not quiet:
        print(message)


def _rewrite_key(
    results: Dict[str, Any],
    quiet: bool,
    *,
    section: str,
    key: str,
    match: Callable[[Any], bool],
    new: Any,
    added: str,
    message: str,
    extra_guard: Callable[[Dict[str, Any]], bool] = lambda _m: True,
    create_section: bool = False) -> None:
    """Rewrite ``<section>.<key>`` to *new* (None = delete) when ``match(current)`` holds; a
    missing section is skipped unless *create_section*."""
    config = read_raw_config()
    raw = config.get(section)
    if not isinstance(raw, dict):
        if not create_section:
            return
        raw = {}
    if match(raw.get(key)) and extra_guard(raw):
        if new is None:
            del raw[key]
        else:
            raw[key] = new
        config[section] = raw
        _commit(config, results, quiet, added, message)


def _rewrite_stale_default(*, old: Any, **kw: Any) -> Callable[[Dict[str, Any], bool], None]:
    """Step rewriting a key only while it still equals the OLD default — never clobbers a value
    the user customized; unset keys inherit the new default at read time."""
    return functools.partial(_rewrite_key, match=lambda cur: cur == old, **kw)


def _lower_is(word: str) -> Callable[[Any], bool]:
    return lambda cur: isinstance(cur, str) and cur.strip().lower() == word


def _migrate_to_12(results: Dict[str, Any], quiet: bool) -> None:
    # 11 → 12: custom_providers list → providers dict.
    _custom_provider_entry_to_provider_config = _cfg()._custom_provider_entry_to_provider_config

    config = read_raw_config()
    custom_list = config.get("custom_providers")
    if not (isinstance(custom_list, list) and custom_list):
        return
    providers_dict = _dict_at(config, "providers")
    migrated_count = 0
    for entry in custom_list:
        if not isinstance(entry, dict):
            continue
        old_name = entry.get("name", "")
        if not isinstance(old_name, str):  # hand-edited name: 5 must not crash .strip()
            old_name = ""
        old_url = entry.get("base_url", "") or entry.get("url", "") or entry.get("api", "") or ""
        if not old_url:
            continue

        # kebab-case key from the display name; fall back to the URL hostname.
        key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "")
        key = re.sub(r"-{2,}", "-", key).strip("-")
        if not key:
            try:
                key = (urlparse(old_url).hostname or "endpoint").replace(".", "-")
            except Exception:
                key = f"endpoint-{migrated_count}"

        # Don't overwrite existing entries
        base_key = key
        suffix = migrated_count
        while key in providers_dict:
            key = f"{base_key}-{suffix}"
            suffix += 1

        new_entry = _custom_provider_entry_to_provider_config(entry, provider_key=key)
        if new_entry is None:
            continue
        if not old_name:
            new_entry.pop("name", None)
        if new_entry.get("api_key") in {"no-key", "no-key-required", ""}:
            new_entry.pop("api_key", None)

        providers_dict[key] = new_entry
        migrated_count += 1

    if migrated_count > 0:
        config["providers"] = providers_dict
        # Runtime reads the list view via get_compatible_custom_providers().
        config.pop("custom_providers", None)
        _persist_migration(config)
        if not quiet:
            print(f"  ✓ Migrated {migrated_count} custom provider(s) to providers: section")
            for key in list(providers_dict.keys())[-migrated_count:]:
                print(f"    → {key}: {providers_dict[key].get('api', '')}")


def _migrate_to_13(results: Dict[str, Any], quiet: bool) -> None:
    # 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env (written by the old setup wizard;
    # nothing reads them — config.yaml is the sole source of truth).
    _c = _cfg()
    for dead_var in ("LLM_MODEL", "OPENAI_MODEL"):
        try:
            if _c.get_env_value(dead_var):
                _c.save_env_value(dead_var, "")
                if not quiet:
                    print(f"  ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)")
        except Exception:
            pass


_LOCAL_WHISPER_MODELS = frozenset({
    "tiny.en", "tiny", "base.en", "base", "small.en", "small",
    "medium.en", "medium", "large-v1", "large-v2", "large-v3",
    "large", "distil-large-v2", "distil-medium.en",
    "distil-small.en", "distil-large-v3", "distil-large-v3.5",
    "large-v3-turbo", "turbo"})


def _migrate_to_14(results: Dict[str, Any], quiet: bool) -> None:
    # 13 → 14: legacy flat stt.model → provider section. A provider-agnostic `stt.model` fed
    # OpenAI names to faster-whisper ("Invalid model size"). Only the raw (user-written) config
    # decides; a nested model the user already set is never overwritten.
    raw_stt = read_raw_config().get("stt", {})
    if not (isinstance(raw_stt, dict) and "model" in raw_stt):
        return
    legacy_model = raw_stt["model"]
    provider = raw_stt.get("provider", "local")
    if not isinstance(provider, str):  # a mapping/int provider has no valid target section
        provider = "local"
    config = read_raw_config()
    stt = config.get("stt", {})
    stt.pop("model", None)

    def _place(section: str) -> None:
        existing = raw_stt.get(section, {})
        if not isinstance(existing, dict) or "model" not in existing:
            target = stt.get(section)
            if not isinstance(target, dict):  # stt.<section>: 5 — replace, don't index a scalar
                target = stt[section] = {}
            target["model"] = legacy_model

    if provider in {"local", "local_command"}:
        # An OpenAI model name is dropped; the local section already defaults to "base".
        if isinstance(legacy_model, str) and legacy_model in _LOCAL_WHISPER_MODELS:
            _place("local")
    else:
        _place(provider)
    config["stt"] = stt
    _commit(
        config, results, quiet, None, "  ✓ Migrated legacy stt.model to provider-specific config")


def _migrate_to_16(results: Dict[str, Any], quiet: bool) -> None:
    # 15 → 16: display.tool_progress_overrides → display.platforms.<plat>.tool_progress.
    config = read_raw_config()
    display = _dict_at(config, "display")
    old_overrides = display.get("tool_progress_overrides")
    if not (isinstance(old_overrides, dict) and old_overrides):
        return
    platforms = _dict_at(display, "platforms")
    for plat, mode in old_overrides.items():
        target = platforms.get(plat)
        if not isinstance(target, dict):  # platforms.<plat>: 5 — replace, don't index a scalar
            target = platforms[plat] = {}
        if "tool_progress" not in target:
            target["tool_progress"] = mode
    display["platforms"] = platforms
    config["display"] = display
    migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items())
    _commit(
        config, results, quiet,
        "display.platforms (migrated from tool_progress_overrides)",
        f"  ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")


def _migrate_to_17(results: Dict[str, Any], quiet: bool) -> None:
    # 16 → 17: remove legacy compression.summary_* keys; non-empty, non-default values move to
    # auxiliary.compression without overriding an explicit (non-"auto") aux value.
    config = read_raw_config()
    comp = config.get("compression", {})
    if not isinstance(comp, dict):
        return
    legacy = {k: comp.pop(f"summary_{k}", None) for k in ("model", "provider", "base_url")}
    migrated_keys = []
    for k, raw in legacy.items():
        val = str(raw).strip() if raw else ""
        if not val or (k == "provider" and val == "auto"):
            continue
        aux = config.get("auxiliary")
        if not isinstance(aux, dict):  # auxiliary: 5 — setdefault would index a scalar
            aux = config["auxiliary"] = {}
        aux_comp = aux.get("compression")
        if not isinstance(aux_comp, dict):
            aux_comp = aux["compression"] = {}
        cur = aux_comp.get(k)
        if not cur or (k == "provider" and cur == "auto"):
            aux_comp[k] = val
            migrated_keys.append(f"{k}={raw}")
    if migrated_keys or any(v is not None for v in legacy.values()):
        config["compression"] = comp
        message = (
            "  ✓ Migrated compression.summary_* → auxiliary.compression: "
            f"{', '.join(migrated_keys)}"
            if migrated_keys else "  ✓ Removed unused compression.summary_* keys")
        _commit(config, results, quiet, None, message)


def _installed_user_plugins(disabled: set) -> List[str]:
    """Names of plugins under ``$HERMES_HOME/plugins/`` with a manifest, minus *disabled*."""
    _c = _cfg()
    found: List[str] = []
    try:
        user_plugins_dir = _c.get_hermes_home() / "plugins"
        if user_plugins_dir.is_dir():
            for child in sorted(user_plugins_dir.iterdir()):
                if not child.is_dir():
                    continue
                manifest_file = child / "plugin.yaml"
                if not manifest_file.exists():
                    manifest_file = child / "plugin.yml"
                if not manifest_file.exists():
                    continue
                try:
                    with open(manifest_file, encoding="utf-8-sig") as _mf:
                        manifest = _c.fast_safe_load(_mf) or {}
                except Exception:
                    manifest = {}
                name = manifest.get("name") or child.name
                if name not in disabled:
                    found.append(name)
    except Exception:
        return []
    return found


def _migrate_to_21(results: Dict[str, Any], quiet: bool) -> None:
    # 20 → 21: plugins are now opt-in (loader requires ``plugins.enabled``). Grandfather installed
    # user plugins not already disabled; bundled plugins ship off and need explicit opt-in.
    config = read_raw_config()
    plugins_cfg = _dict_at(config, "plugins")
    if "enabled" in plugins_cfg:
        return
    disabled = plugins_cfg.get("disabled", []) or []
    grandfathered = _installed_user_plugins(set(disabled) if isinstance(disabled, list) else set())
    plugins_cfg["enabled"] = grandfathered
    config["plugins"] = plugins_cfg
    message = (
        f"  ✓ Plugins now opt-in: grandfathered "
        f"{len(grandfathered)} existing plugin(s) into plugins.enabled"
        if grandfathered else
        "  ✓ Plugins now opt-in: no existing plugins to grandfather. "
        "Use `hermes plugins enable <name>` to activate.")
    _commit(
        config, results, quiet,
        f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)", message)


def _migrate_to_23(results: Dict[str, Any], quiet: bool) -> None:
    # 22 → 23: seed curator defaults + create logs/curator/. Older configs never wrote the curator
    # section; deep-merge made it work but users could not see/edit it and `hermes curator status`
    # had no stable logs dir. Only keys the user hasn't set are written.
    _c = _cfg()
    DEFAULT_CONFIG = _c.DEFAULT_CONFIG

    try:
        curator_dir = _c.get_hermes_home() / "logs" / "curator"
        curator_dir.mkdir(parents=True, exist_ok=True)
    except Exception as e:
        results["warnings"].append(f"Could not create {curator_dir}: {e}")

    config = read_raw_config()

    def _seed_missing(section: Dict[str, Any], defaults: Dict[str, Any]) -> List[str]:
        added = [k for k in defaults if k not in section]
        for k in added:
            section[k] = copy.deepcopy(defaults[k])
        return added

    raw_curator = _dict_at(config, "curator")
    added_curator = _seed_missing(raw_curator, DEFAULT_CONFIG.get("curator", {}))
    if added_curator:
        config["curator"] = raw_curator

    raw_aux = _dict_at(config, "auxiliary")
    raw_aux_curator = _dict_at(raw_aux, "curator")
    added_aux = _seed_missing(
        raw_aux_curator, DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {}))
    if added_aux:
        raw_aux["curator"] = raw_aux_curator
        config["auxiliary"] = raw_aux

    if added_curator or added_aux:
        _persist_migration(config)
        for label, added in (("curator", added_curator), ("auxiliary.curator", added_aux)):
            if not added:
                continue
            results["config_added"].append(f"{label} ({len(added)} default key(s))")
            if not quiet:
                print(
                    f"  ✓ {'Curator' if label == 'curator' else label} settings now available "
                    f"({', '.join(added)}) — edit via `hermes config set`")


def _migrate_to_29(results: Dict[str, Any], quiet: bool) -> None:
    # 28 → 29: memory/skills tri-state write_mode (on|off|approve) → boolean write_approval.
    # Only "approve" carried gating intent → true; the old "off = block writes" mode is dropped
    # (memory_enabled: false disables memory). Only a persisted key is rewritten.
    config = read_raw_config()
    touched = False
    for subsystem in ("memory", "skills"):
        sub = config.get(subsystem)
        if not isinstance(sub, dict) or "write_mode" not in sub:
            continue
        old = sub.pop("write_mode")
        old_norm = old.strip().lower() if isinstance(old, str) else old
        sub["write_approval"] = (old_norm == "approve")
        config[subsystem] = sub
        touched = True
        results["config_added"].append(
            f"{subsystem}.write_mode → write_approval={sub['write_approval']}")
    if touched:
        _commit(config, results, quiet, None,
                "  ✓ Renamed write_mode → write_approval (boolean gate)")


# 29 → 30 (curator.consolidate defaults to false) is schema-default-only: deep-merge supplies it
# at read time and persisting a default would only bloat a lean config. No registry entry.


def _migrate_to_33(results: Dict[str, Any], quiet: bool) -> None:
    # 32 → 33: max_async_children is deprecated; fold a raised value into max_concurrent_children
    # (take the max so nobody loses headroom), then drop it.
    config = read_raw_config()
    raw_deleg = config.get("delegation")
    if not (isinstance(raw_deleg, dict) and "max_async_children" in raw_deleg):
        return
    old_async = raw_deleg.pop("max_async_children")
    try:
        old_async_i = int(old_async)
    except (TypeError, ValueError):
        old_async_i = None
    if old_async_i is not None and old_async_i > 3:
        try:
            cur_children = int(raw_deleg.get("max_concurrent_children", 3))
        except (TypeError, ValueError):
            cur_children = 3
        if old_async_i > cur_children:
            raw_deleg["max_concurrent_children"] = old_async_i
            results["config_added"].append(
                f"delegation.max_concurrent_children={old_async_i} "
                f"(folded from deprecated max_async_children)")
    config["delegation"] = raw_deleg
    _commit(
        config, results, quiet, None,
        "  ✓ Removed deprecated delegation.max_async_children — "
        "delegation.max_concurrent_children now caps background "
        "delegations too.")


def _migrate_to_34(results: Dict[str, Any], quiet: bool) -> None:
    # 33 → 34: one-time personality reset. Persistence used to be split (TUI/desktop wrote the
    # NAME to display.personality, CLI/gateway wrote rendered TEXT to agent.system_prompt), so
    # once display.personality became authoritative, stale names resurrected personalities users
    # had turned off. Reset display.personality → "" and scrub agent.system_prompt ONLY when it
    # verbatim-equals a known personality's rendered text; any other text is user-owned.
    from hermes_cli.personality import (
        available_personalities, normalize_personality_name, prompt_text, render_personality_prompt)

    config = read_raw_config()
    touched = False

    raw_display = config.get("display")
    old_name = ""
    if isinstance(raw_display, dict):
        old_name = normalize_personality_name(raw_display.get("personality", ""))
        if old_name:
            raw_display["personality"] = ""
            config["display"] = raw_display
            touched = True

    raw_agent = config.get("agent")
    scrubbed_text = False
    if isinstance(raw_agent, dict):
        manual = prompt_text(raw_agent.get("system_prompt", ""))
        if manual:
            rendered = {
                render_personality_prompt(defn) for defn in available_personalities(config).values()
            }
            if manual in rendered:
                raw_agent["system_prompt"] = ""
                config["agent"] = raw_agent
                touched = True
                scrubbed_text = True

    if not touched:
        return
    _commit(config, results, quiet, "display.personality=none (one-time reset)", None)
    if quiet:
        return
    if old_name:
        print(
            f"  ✓ Personality reset to none (was '{old_name}'). Personality "
            "state was previously saved inconsistently across surfaces and "
            "could re-enable a personality you had turned off. "
            f"Run /personality {old_name} to turn it back on.")
    if scrubbed_text:
        print(
            "  ✓ Removed personality text from agent.system_prompt (written "
            "by an older /personality). That field is now reserved for "
            "manual system prompts; personalities live in display.personality.")


def _migrate_to_38(results: Dict[str, Any], quiet: bool) -> None:
    # 37 → 38: the bundled observability/nemo_relay plugin was removed (Relay lifecycle moved
    # into the agent core); drop it from plugins.enabled.
    from hermes_cli.relay_plugin_cutover import legacy_relay_plugin_keys

    config = read_raw_config()
    plugins = config.get("plugins")
    if not isinstance(plugins, dict):
        return
    enabled = plugins.get("enabled")
    removed = legacy_relay_plugin_keys(enabled)
    if not removed or not isinstance(enabled, list):
        return

    plugins["enabled"] = [value for value in enabled if value not in removed]
    config["plugins"] = plugins
    _persist_migration(config)
    message = (
        "Removed legacy Relay plugin from plugins.enabled: "
        f"{', '.join(removed)}. Configure a standard user or system Relay plugins.toml, or use "
        "HERMES_NEMO_RELAY_PLUGINS_TOML for an explicit user-file override.")
    results["warnings"].append(message)
    if not quiet:
        print(f"  ⚠ {message}")


def _migrate_to_39(results: Dict[str, Any], quiet: bool) -> None:
    # 38 → 39: strip the retired `bfl` toolset wherever a backfill/picker save wrote it, so stale
    # config can't resurrect an unknown toolset.
    config = read_raw_config()
    changed = False
    for section in ("platform_toolsets", "known_builtin_toolsets"):
        mapping = config.get(section)
        if not isinstance(mapping, dict):
            continue
        for platform, toolsets in mapping.items():
            if isinstance(toolsets, list) and "bfl" in toolsets:
                mapping[platform] = [ts for ts in toolsets if ts != "bfl"]
                changed = True
        if changed:
            config[section] = mapping
    if changed:
        _commit(
            config, results, quiet,
            "removed retired 'bfl' toolset from saved toolset lists",
            "  ✓ Removed the retired BFL FLUX 3 toolset from saved toolset "
            "lists — video generation now lives under `hermes tools` → "
            "Video Generation (Nous Subscription or FAL).")


def _migrate_to_41(results: Dict[str, Any], quiet: bool) -> None:
    # 40 → 41: drop the plugin-era "## Messaging other agents" append from every SOUL.md. The
    # server injects the live Bot Mode section in Bot Chat sessions; the frozen SOUL copy taxed
    # every other session (~600 tok) and shadowed the live roster in Bot Chat itself.
    from hermes_constants import get_hermes_home
    from tools.bot_mode_probe import _PROTOCOL_HEADING, _hermes_root, _roster, strip_legacy_protocol

    cleaned: List[str] = []
    for name, profile_dir in _roster(_hermes_root(get_hermes_home())):
        soul = profile_dir / "SOUL.md"
        try:
            text = soul.read_text(encoding="utf-8-sig") if soul.is_file() else ""
            if _PROTOCOL_HEADING in text:
                soul.write_text(strip_legacy_protocol(text), encoding="utf-8")
                cleaned.append(name)
        except OSError:
            continue
    if cleaned:
        results["config_added"].append(f"removed legacy Bot Mode section from SOUL.md ({', '.join(cleaned)})")
        if not quiet:
            print(f"  ✓ Removed the plugin-era 'Messaging other agents' section from SOUL.md "
                  f"({', '.join(cleaned)}) — Bot Chat sessions now get the live roster instead.")


def _migrate_to_45(results: Dict[str, Any], quiet: bool) -> None:
    # 44 → 45: append `connections` to every saved `platform_toolsets` list that predates it
    # (an explicit list treats absence as unchecked). Skipped when `known_builtin_toolsets`
    # already records `connections` (a decline) or `agent.disabled_toolsets` names it (the
    # resolver subtracts that list last, so the append would have no effect).
    from agent.skill_utils import parse_config_string_list
    from hermes_cli.tools_config import _configurable_keys, _get_plugin_toolset_keys
    from hermes_cli.toolset_scope import toolset_allowed_for_platform

    config = read_raw_config()
    saved = config.get("platform_toolsets")
    if not isinstance(saved, dict):
        return
    if "connections" in parse_config_string_list(_dict_at(config, "agent").get("disabled_toolsets")):
        return
    known = _dict_at(config, "known_builtin_toolsets")
    # Same predicate the resolver uses to pick its explicit branch: any configurable or plugin key.
    explicit_keys = _configurable_keys() | _get_plugin_toolset_keys()
    enabled_for: List[str] = []
    for platform, toolsets in saved.items():
        if not isinstance(toolsets, list) or "connections" in toolsets:
            continue
        if not toolset_allowed_for_platform("connections", platform):
            continue
        # A composite like [hermes-cli] already inherits every core tool at read time.
        if not any(str(ts) in explicit_keys for ts in toolsets):
            continue
        offered = known.get(platform)
        if isinstance(offered, list) and "connections" in offered:
            continue
        saved[platform] = sorted({*map(str, toolsets), "connections"})
        if isinstance(offered, list):
            known[platform] = sorted({*map(str, offered), "connections"})
        enabled_for.append(str(platform))
    if not enabled_for:
        return
    config["platform_toolsets"] = saved
    if known:
        config["known_builtin_toolsets"] = known
    platforms = ", ".join(sorted(enabled_for))
    _commit(
        config, results, quiet,
        f"enabled the connections toolset for {platforms}",
        f"  ✓ Enabled the Connections toolset (Gmail, Linear, Notion, local MCP servers) for {platforms}. "
        "Uncheck Connections in `hermes tools` to turn it off.")


def _migrate_to_46(results: Dict[str, Any], quiet: bool) -> None:
    # 45 → 46: the profile editor used to switch an MCP server off with `disabled: true`, a key no
    # runtime reader consults, so the server kept running. Carry that choice over to `enabled:
    # false` (the key every reader uses) and drop `disabled`, so the editor and runtime agree.
    # `disabled: true` wins over an explicit `enabled: true`: `hermes mcp add` writes that, and the
    # old editor only added `disabled`, so letting `enabled` win would skip nearly every server.
    from hermes_cli.tools_config import _parse_enabled_flag

    config = read_raw_config()
    servers = config.get("mcp_servers")
    if not isinstance(servers, dict):
        return
    legacy = {n: e for n, e in servers.items() if isinstance(e, dict) and "disabled" in e}
    turned_off = sorted((n for n, e in legacy.items() if _parse_enabled_flag(e["disabled"], default=False)), key=str)
    if not turned_off:
        return  # a falsy `disabled` is inert; the runtime never read it
    for name in turned_off:
        del legacy[name]["disabled"]
        legacy[name]["enabled"] = False
    names = ", ".join(map(str, turned_off))
    _commit(
        config, results, quiet,
        f"mcp_servers: disabled → enabled: false ({names})",
        f"  ✓ Turned off MCP servers the profile editor had marked disabled: {names}.")


def _migrate_to_48(results: Dict[str, Any], quiet: bool) -> None:
    # 47 → 48: the container sandbox default gains a display stack (nousresearch/hermes-sandbox:
    # desktop) so Bot Screen / computer_use / the browser run inside the sandbox. A saved value
    # still equal to the OLD default is the template copied, not a choice: the key is DROPPED so
    # the file follows the default. It is not rewritten to the new image, because a written image
    # is a pin and a pin recreates a persisted Docker container without asking; unpinned, the
    # runtime keeps an existing sandbox and the CLI / Screen pane ask first. A pinned image stays.
    from hermes_cli.config_defaults import DEFAULT_SANDBOX_IMAGE, LEGACY_SANDBOX_IMAGES
    for legacy in LEGACY_SANDBOX_IMAGES:
        for key, old in (
            ("docker_image", legacy),
            ("modal_image", legacy),
            ("daytona_image", legacy),
            ("singularity_image", f"docker://{legacy}"),
        ):
            _rewrite_stale_default(
                section="terminal", key=key, old=old, new=None,
                added=f"terminal.{key} unset (follows the default, {DEFAULT_SANDBOX_IMAGE})",
                message=f"  ✓ terminal.{key}: was the old default; now follows the default sandbox image "
                        f"({DEFAULT_SANDBOX_IMAGE})",
            )(results, quiet)


#: Registry of (target_version, step), strictly ascending; simple default-flip steps are
#: declared inline via _rewrite_stale_default / _rewrite_key partials. Later steps observe
#: earlier steps' writes via read_raw_config() (filesystem state). v12 is the support floor:
#: configs already AT v12 still get every step below; only configs BELOW 12 are refused by the
#: floor gate in run_migrations()'s caller. Versions absent here (15, 18-20, 22, 24, 26-28, 30)
#: only added a schema default that runtime merging supplies without a write. When adding a step,
#: decide whether it belongs in LEGACY_KEY_STEPS below (the only steps an unversioned file gets).

def _migrate_to_49(results: Dict[str, Any], quiet: bool) -> None:
    # 48 → 49: Vercel deprecated sandbox runtimes in favour of images; the default moves from the
    # `node24` runtime to `vercel/sandbox/universal:latest`. A saved runtime still equal to the old
    # seeded default (config.yaml AND the .env mirror the setup wizard wrote) is the template copied,
    # not a choice, so both are dropped and fresh sandboxes follow terminal.vercel_image. A runtime
    # the user chose (node22, python3.13) stays and keeps overriding the image, as before. Persisted
    # sandboxes are unaffected either way: a snapshot restore never sends a runtime or an image.
    from hermes_cli.config_defaults import DEFAULT_VERCEL_IMAGE, LEGACY_VERCEL_RUNTIME
    _rewrite_stale_default(
        section="terminal", key="vercel_runtime", old=LEGACY_VERCEL_RUNTIME, new=None,
        added=f"terminal.vercel_runtime unset (fresh sandboxes use terminal.vercel_image, {DEFAULT_VERCEL_IMAGE})",
        message=f"  ✓ terminal.vercel_runtime: was the old default; fresh sandboxes now use the managed image "
                f"({DEFAULT_VERCEL_IMAGE})",
    )(results, quiet)
    _c = _cfg()
    if (_c.get_env_value_prefer_dotenv("TERMINAL_VERCEL_RUNTIME") or "").strip() == LEGACY_VERCEL_RUNTIME:
        _c.remove_env_value("TERMINAL_VERCEL_RUNTIME")
        if not quiet:
            print("  ✓ Cleared TERMINAL_VERCEL_RUNTIME from .env (was the old default; the image is used instead)")


MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = (
    (12, _migrate_to_12),
    (13, _migrate_to_13),
    (14, _migrate_to_14),
    (16, _migrate_to_16),
    (17, _migrate_to_17),
    (21, _migrate_to_21),
    (23, _migrate_to_23),
    # 24 → 25: model_catalog TTL 24h → 1h (only the OLD default 24).
    (25, _rewrite_stale_default(
        section="model_catalog", key="ttl_hours", old=24, new=1,
        added="model_catalog.ttl_hours 24→1",
        message="  ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")),
    (29, _migrate_to_29),
    # 30 → 31: verify_on_stop OFF (one-time). The "auto" sentinel was more noise than signal.
    # Rewrite only when missing or still "auto" — an explicit user true/false is preserved.
    (31, functools.partial(
        _rewrite_key, section="agent", key="verify_on_stop", new=False, create_section=True,
        match=lambda cur: cur is None or _lower_is("auto")(cur),
        added="agent.verify_on_stop=false",
        message=(
            "  ✓ Turned off verify-on-stop (agent.verify_on_stop: false). "
            "Set it to true to re-enable, or \"auto\" for the legacy "
            "surface-aware behavior."))),
    # 31 → 32: flip the BAKED-IN literal true to OFF (one-time). v30 defaulted verify_on_stop to a
    # literal True and migrate_config persisted defaults, so installs that updated through v30 have
    # `verify_on_stop: true` written literally — never a user choice (no off-switch existed until
    # v31). A true set AFTER v32 is never touched.
    (32, _rewrite_stale_default(
        section="agent", key="verify_on_stop", old=True, new=False,
        added="agent.verify_on_stop=false",
        message=(
            "  ✓ Turned off verify-on-stop (agent.verify_on_stop: false) — "
            "the old default was written into your config as a literal "
            "true. Set it to true again to re-enable, or \"auto\" for the "
            "legacy surface-aware behavior."),
        extra_guard=lambda raw: raw.get("verify_on_stop") is True)),
    (33, _migrate_to_33),
    (34, _migrate_to_34),
    # 34 → 35: background_process_notifications 'all' (old implicit default, rarely chosen on
    # purpose) → 'concise'. Explicit result/error/off choices are preserved.
    (35, functools.partial(
        _rewrite_key, section="display", key="background_process_notifications",
        match=_lower_is("all"), new="concise",
        added="display.background_process_notifications=concise (was: all)",
        message=(
            "  ✓ Background process notifications switched from 'all' to "
            "'concise' — completions now show a one-line status message "
            "instead of the raw output dump. Set "
            "display.background_process_notifications: all to restore "
            "the old behavior."))),
    # 35 → 36: subagent iteration cap 50 → 250 (50 truncated substantial delegated work).
    (36, _rewrite_stale_default(
        section="delegation", key="max_iterations", old=50, new=250,
        added="delegation.max_iterations=250 (was: 50)",
        message=(
            "  ✓ Raised delegation.max_iterations from 50 to 250 — subagents "
            "now get a larger per-child tool-call budget so delegated work "
            "finishes instead of truncating. Set delegation.max_iterations "
            "back to 50 to restore the old cap."))),
    # 36 → 37: delegation concurrency 3 → 10 (stays at/below the high-cost warning threshold).
    (37, _rewrite_stale_default(
        section="delegation", key="max_concurrent_children", old=3, new=10,
        added="delegation.max_concurrent_children=10 (was: 3)",
        message=(
            "  ✓ Raised delegation.max_concurrent_children from 3 to 10 — "
            "independent delegated children now fan out wider in parallel. "
            "Each child consumes API tokens independently; set "
            "delegation.max_concurrent_children back to 3 to restore the old cap."))),
    (38, _migrate_to_38),
    (39, _migrate_to_39),
    # 39 → 40: model_catalog.ttl_hours → ttl_minutes (default 20). Only the OLD default
    # (ttl_hours: 1, written by v25) is dropped; any other explicit ttl_hours is still honoured.
    (40, _rewrite_stale_default(
        section="model_catalog", key="ttl_hours", old=1, new=None,
        added="model_catalog.ttl_hours 1 → ttl_minutes 20 (default)",
        message="  ✓ Model catalog now refreshes every 20 minutes (model_catalog.ttl_minutes)",
        extra_guard=lambda raw: "ttl_minutes" not in raw)),
    (41, _migrate_to_41),
    # 41 → 42: cron.model_drift_guard is gone. Unpinned jobs now run on their creation snapshot
    # instead of failing closed when the global model changes, so the toggle has nothing to gate.
    (42, functools.partial(
        _rewrite_key, section="cron", key="model_drift_guard", new=None,
        match=lambda cur: cur is not None,
        added="removed cron.model_drift_guard",
        message=(
            "  ✓ Removed cron.model_drift_guard — unpinned cron jobs now keep running on the "
            "model/provider they were created under when the global default changes, instead "
            "of being skipped. Pin a job or set cron.model to move it."))),
    # 42 → 43: gateway.multiplex_profile_allowlist is gone. A multiplexing default gateway serves
    # every live profile under profiles/; a profile that must not be served is archived or deleted.
    (43, functools.partial(
        _rewrite_key, section="gateway", key="multiplex_profile_allowlist", new=None,
        match=lambda _cur: True,
        added="removed gateway.multiplex_profile_allowlist",
        message=(
            "  ✓ Removed gateway.multiplex_profile_allowlist — the multiplexing gateway now serves "
            "every profile under profiles/. Delete or archive a profile you do not want served."),
        extra_guard=lambda raw: "multiplex_profile_allowlist" in raw)),
    # 43 → 44: curator prunes faster — stale 30→14 days, archive 90→30 days. A skill nobody has
    # touched in a month is prompt weight, not knowledge; archival is recoverable. Only the OLD
    # defaults are rewritten; an explicit user value is preserved.
    (44, _rewrite_stale_default(
        section="curator", key="stale_after_days", old=30, new=14,
        added="curator.stale_after_days=14 (was: 30)",
        message="  ✓ curator.stale_after_days 30→14 — unused skills are flagged stale after two weeks.")),
    (44, _rewrite_stale_default(
        section="curator", key="archive_after_days", old=90, new=30,
        added="curator.archive_after_days=30 (was: 90)",
        message=(
            "  ✓ curator.archive_after_days 90→30 — skills unused for a month are archived to "
            "skills/.archive/ (recoverable with `hermes curator restore`). Set it back to 90 to keep the old window."))),
    # 44 → 45: saved platform_toolsets lists predate the connections toolset (see _migrate_to_45).
    (45, _migrate_to_45),
    # 45 → 46: legacy editor `disabled: true` on MCP servers becomes `enabled: false` (see _migrate_to_46).
    (46, _migrate_to_46),
    # 46 → 47: compression.threshold_tokens defaults back to null (ratio-only). The briefly shipped
    # 256000 default was copied into config.yaml by the template seeder and `doctor --fix`, where it
    # reads as a user choice and keeps capping 1M-window models at 256K. Drop only that exact value;
    # any other explicit cap, and an explicit null, are preserved.
    (47, _rewrite_stale_default(
        section="compression", key="threshold_tokens", old=256000, new=None,
        added="removed compression.threshold_tokens: 256000 (the old default)",
        message=(
            "  ✓ Removed compression.threshold_tokens: 256000 — the old default. Compaction "
            "follows compression.threshold (50% of the window) again. Set threshold_tokens "
            "to a token count to cap it on purpose."))),
    # 47 → 48: a saved old-default sandbox image is dropped so the file follows the new default (see _migrate_to_48).
    (48, _migrate_to_48),
    # 48 → 49: the seeded Vercel runtime pin is dropped so fresh sandboxes use the managed image (see _migrate_to_49).
    (49, _migrate_to_49),
)

#: Steps triggered by a legacy key or identifier (a renamed or retired key, a removed plugin or
#: toolset, the plugin-era SOUL.md section): they carry its setting to where the runtime reads it
#: or drop what nothing reads, which is right however old the file is. A config.yaml with no
#: ``_config_version`` is current-schema content that was never stamped (installers seed it from
#: cli-config.yaml.example; targeted writers never stamp), so it gets only these: every other step
#: decides by a value or an absence that, in such a file, is the user's own choice. v13 is left
#: out: it clears OPENAI_MODEL from .env, a generic name Hermes never reads but the user's tools may.
#: v41 is left out too: it rewrites profile SOUL.md on a heading match, an artifact whose
#: provenance the config stamp says nothing about.
LEGACY_KEY_STEPS = frozenset({12, 14, 16, 17, 29, 33, 38, 39, 42, 43, 46})


def run_migrations(
    current_ver: int, results: Dict[str, Any], quiet: bool, *, unversioned: bool = False) -> None:
    """Apply every registered migration whose target version exceeds *current_ver*; a config
    with no ``_config_version`` (*unversioned*) gets only :data:`LEGACY_KEY_STEPS`.

    *current_ver* is the on-disk schema version captured ONCE before any step runs and does not
    advance between steps — each step is gated on the same initial value.
    """
    for target_ver, migration_fn in MIGRATIONS:
        if current_ver < target_ver and (target_ver in LEGACY_KEY_STEPS or not unversioned):
            try:
                migration_fn(results, quiet)
            except Exception as exc:
                # A malformed nested value in one step must not abort the rest of the
                # ladder (config loading itself fails otherwise). Loud, not silent.
                warning = f"config migration to v{target_ver} failed and was skipped: {exc}"
                results.setdefault("warnings", []).append(warning)
                # Quiet callers (profile creation, unattended update) discard ``results`` and
                # migrate_config still stamps the latest version, so without a log line the
                # skipped step vanishes for good.
                logger.warning("%s", warning)
                if not quiet:
                    print(f"  ⚠ {warning}")
