"""
Single source of truth for agent config (mugi_config.toml).
Wraps persistence for load/save; provides typed accessors and optional cache.
"""
import hashlib
import os
import re
import time
import logging

_cached_config = None
_last_reload_sig: tuple[int, int] | None = None
# Monotonic timestamp of the last on-disk staleness check (see get_config).
_last_stat_check_monotonic: float = 0.0
# Re-check the on-disk config signature at most this often on the cached path.
# Bounds the stat() rate on hot paths while still letting long-lived processes
# (MCP sidecar, dispatcher, heartbeats) pick up a config flip — e.g. the kanban
# kill switch — within a couple seconds, with no explicit reload or restart.
_CONFIG_STAT_TTL_S = 2.0


def _config_sig() -> tuple[int, int] | None:
    try:
        from persistence import MUGI_CONFIG_PATH
        st = os.stat(MUGI_CONFIG_PATH)
        return (int(st.st_mtime_ns), int(st.st_size))
    except Exception:
        return None


def _sync_runtime_debug_state(config: dict | None = None) -> None:
    """Apply in-process debug side effects after config load/save."""
    try:
        from tools.conversation_trace import reset_trace_config
        reset_trace_config()
    except Exception:
        pass

    cfg = config if isinstance(config, dict) else get_config(use_cache=True)
    enabled = bool(((cfg or {}).get("debug") or {}).get("enabled", False))
    target_level = logging.INFO if enabled else logging.WARNING
    for name in ("", "mugi_backend", "uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
        try:
            logging.getLogger(name).setLevel(target_level)
        except Exception:
            pass


def get_config(use_cache: bool = True):
    """
    Load agent config from ~/.mugi/mugi_config.toml.
    Returns dict with general, personality_files, debug, onboarding, summaries.

    If use_cache is True, returns the cached config until reload_config() is
    called OR the on-disk file changes. The disk check is bounded to once every
    _CONFIG_STAT_TTL_S so hot paths don't stat() on every call, while long-lived
    processes still self-heal after a config flip (e.g. the kanban kill switch)
    without an explicit reload_config() or a process restart.
    """
    global _cached_config, _last_reload_sig, _last_stat_check_monotonic
    if use_cache and _cached_config is not None:
        now = time.monotonic()
        if now - _last_stat_check_monotonic >= _CONFIG_STAT_TTL_S:
            _last_stat_check_monotonic = now
            current = _config_sig()
            if current is not None and current != _last_reload_sig:
                return reload_config()
        return _cached_config
    from persistence import load_agent_config
    _cached_config = load_agent_config()
    _last_reload_sig = _config_sig()
    _last_stat_check_monotonic = time.monotonic()
    return _cached_config


def reload_config():
    """Invalidate cache and load config fresh from disk."""
    global _cached_config, _last_reload_sig
    _cached_config = None
    config = get_config(use_cache=False)
    _sync_runtime_debug_state(config)
    _last_reload_sig = _config_sig()
    return config


def apply_persisted_config_migrations(*, save: bool = True) -> bool:
    """One-time config normalizations (searx fallback, email schema). Not for GET /preferences."""
    from persistence import load_agent_config

    config = load_agent_config()
    if not isinstance(config, dict):
        return False
    changed = False
    search = config.get("search") if isinstance(config.get("search"), dict) else {}
    raw_fb = search.get("public_fallback_url") if isinstance(search, dict) else ""
    if isinstance(raw_fb, str) and raw_fb.strip() == "https://searx.be":
        config.setdefault("search", {})["public_fallback_url"] = "https://searx.party"
        changed = True
    normalized_email, email_changed = normalize_email_config(
        config.get("email") if isinstance(config.get("email"), dict) else {}
    )
    if email_changed:
        config["email"] = normalized_email
        changed = True
    if changed and save:
        save_config(config)
        reload_config()
    return changed


def reload_config_if_changed() -> dict:
    """Reload config only when mugi_config.toml changed on disk.

    This helper is intended for hot paths (e.g. run_raw_agent). Keep reload_config()
    as the explicit "always refresh from disk and reset runtime debug side effects" API.
    """
    global _last_reload_sig
    current = _config_sig()
    if _cached_config is None:
        return reload_config()
    if current is None:
        return reload_config()
    if _last_reload_sig != current:
        return reload_config()
    return _cached_config


def get_workspace_path() -> str:
    """Return absolute, expanded workspace path (e.g. ~/.mugi/workspace)."""
    config = get_config()
    raw = (config.get("general") or {}).get("workspace_path")
    if not raw or not str(raw).strip():
        from persistence import MUGI_WORKSPACE
        return os.path.abspath(os.path.expanduser(MUGI_WORKSPACE))
    return os.path.abspath(os.path.expanduser(str(raw).strip()))


def get_name() -> str:
    """Return agent name from config (may be set by load_personality_prompt from IDENTITY.md)."""
    config = get_config()
    return (config.get("general") or {}).get("name") or "Assistant"


def parse_identity_name_from_markdown(content: str) -> str | None:
    """Extract display name from IDENTITY.md-style markdown.

    Matches ``- **Name:**`` / ``**Name:**`` first, then plain ``Name:`` lines (legacy).
    Returns None if no usable name (same rules as :func:`get_agent_display_name`).
    """
    if not content or not isinstance(content, str):
        return None
    for raw in content.splitlines():
        line = raw.strip()
        if not line:
            continue
        m = re.match(r"^[-*]\s*\*\*Name:\*\*\s*(.*)$", line, re.IGNORECASE)
        if not m:
            m = re.match(r"^\*\*Name:\*\*\s*(.*)$", line, re.IGNORECASE)
        if m:
            val = (m.group(1) or "").strip()
            if not val or val.startswith("*") or val.startswith("Fill this"):
                continue
            val = val.split("\n", 1)[0].strip()
            if 1 <= len(val) <= 120:
                return val
    for raw in content.splitlines():
        line = raw.strip()
        if not line or "**" in line:
            continue
        m = re.match(r"^\s*Name\s*:\s*(.+)$", line, re.IGNORECASE)
        if m:
            val = (m.group(1) or "").strip()
            val = val.split("\n", 1)[0].strip()
            if 1 <= len(val) <= 120:
                return val
    return None


def get_agent_display_name() -> str:
    """Name for system prompts: prefer **Name:** from workspace PERSONA.md or IDENTITY.md, else config."""
    fallback = (get_name() or "Assistant").strip() or "Assistant"
    try:
        ws = get_workspace_path()
        for filename in ("PERSONA.md", "IDENTITY.md"):
            path = os.path.join(ws, filename)
            if not os.path.isfile(path):
                continue
            with open(path, "r", encoding="utf-8", errors="replace") as f:
                chunk = f.read(12_000)
            parsed = parse_identity_name_from_markdown(chunk)
            if parsed:
                return parsed
    except OSError:
        return fallback
    return fallback


def get_user_display_name() -> str | None:
    """Human's preferred name from workspace USER.md ``**Name:**`` (same rules as :func:`parse_identity_name_from_markdown`).

    Used for trusted-context hints (e.g. mesh) so agents can disambiguate people across peers.
    Returns None if USER.md is missing or has no usable name.
    """
    try:
        ws = get_workspace_path()
        path = os.path.join(ws, "USER.md")
        if not os.path.isfile(path):
            return None
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            chunk = f.read(12_000)
    except OSError:
        return None
    parsed = parse_identity_name_from_markdown(chunk)
    return parsed if parsed else None


def get_conversation_title_utility_milestones() -> list[int]:
    """User message counts (excluding the ``init`` trigger) to regenerate the sidebar title via the utility model.

    Read from ``[general].conversation_title_utility_milestones`` (TOML array of
    positive integers). Default: ``[3, 15]``.
    """
    raw = (get_config().get("general") or {}).get("conversation_title_utility_milestones")
    out: list[int] = []
    if isinstance(raw, (list, tuple)):
        for x in raw:
            try:
                n = int(x)
            except (TypeError, ValueError):
                continue
            if n > 0:
                out.append(n)
    if not out:
        return [3, 15]
    return sorted(set(out))


def evolution_enabled() -> bool:
    """Return whether evolution mode (constitution, JOURNAL, consciousness heartbeat, commit tool) is enabled."""
    return bool((get_config().get("general") or {}).get("evolution_enabled", False))


def debug_enabled() -> bool:
    """Return whether debug logging is enabled in config."""
    config = get_config()
    return bool((config.get("debug") or {}).get("enabled", False))


def get_subagent_config() -> dict:
    """Return [subagent] section: enabled, timeout_seconds, model, base_url, api_key."""
    sec = (get_config().get("subagent") or {}).copy()
    sec.setdefault("enabled", True)
    sec.setdefault("timeout_seconds", 480)
    sec.setdefault("max_retries", 1)
    sec.setdefault("handoff_blackboard_auto", True)
    sec.setdefault("handoff_blackboard_max_inject_chars", 3500)
    sec.setdefault("handoff_blackboard_post_append", True)
    sec.setdefault("handoff_critic_enabled", False)
    sec.setdefault("subagent_first_routing", False)
    # 1 = phase-1 read tools only, 2 = + OCR, 3 = + write/shell (default).
    sec.setdefault("routing_phase", 3)
    sec.setdefault("max_concurrent", 3)
    return sec


# ---------------------------------------------------------------------------
# Kanban — durable shared task board (Hermes-style).
#
# Default per-profile concurrency mirrors the heaviness of each profile:
# heavier profiles (research / extraction) get a lower cap so they cannot
# starve the laptop; lightweight read-only profiles get more room.
# ---------------------------------------------------------------------------
_KANBAN_DEFAULT_CONCURRENCY: dict[str, int] = {
    "research": 2,
    "web_research_light": 3,
    "extraction": 2,
    "code_readonly": 4,
    "ops_cautious": 1,
    "general": 3,
    "bash": 1,
    "triage": 0,
    "council_researcher": 1,
    "council_verifier": 1,
    "council_contrarian": 1,
}


def get_kanban_config() -> dict:
    """Return [kanban] section with safe defaults.

    Used by `kanban_storage`, `utils.kanban_dispatcher`, and the agent-tier
    `kanban_create_task` tool. The `enabled` flag is the master kill switch
    (see plan §"Kill switch" / kanban_storage.py guards).
    """
    sec = (get_config().get("kanban") or {}).copy()
    sec.setdefault("enabled", True)
    sec.setdefault("tick_seconds", 5)
    sec.setdefault("default_claim_ttl_seconds", 900)
    sec.setdefault("default_deadline_seconds", 0)  # 0 = no deadline
    # After a task deadline, cooperative stop_flag is set first; if the worker
    # thread has not exited within this grace window, the dispatcher frees the
    # per-profile concurrency slot and records deadline_expired (WCR-07).
    sec.setdefault("deadline_join_grace_seconds", 120)
    sec.setdefault("default_worker_timeout_seconds", 900)
    sec.setdefault("failure_limit", 3)
    sec.setdefault("observe_loop_hard_stop_threshold", 6)
    # When true, kanban workers get a parseable trailing kanban-result fence synthesized
    # at run end if the model exits with prose only (dispatcher completion contract).
    sec.setdefault("synthesize_missing_contract", True)
    # Salvage SLO (reliability codification Phase 0): harness-synthesized / in-band /
    # salvaged handoffs should be a rare backup, not the norm. The diagnostics doctor probe
    # flags when the salvage rate over the last `salvage_stats_window` terminal runs exceeds
    # `salvage_slo_max`, but only once at least `salvage_slo_min_sample` runs exist (so a
    # fresh install with 1/1 salvaged does not false-alarm). These are observability knobs;
    # they never disable any fallback.
    sec.setdefault("salvage_slo_max", 0.25)
    sec.setdefault("salvage_stats_window", 200)
    sec.setdefault("salvage_slo_min_sample", 20)
    sec.setdefault("research_gather_loop_hard_stop_threshold", 6)
    sec.setdefault("retention_days", 14)
    sec.setdefault("max_runs_per_task", 50)
    sec.setdefault("max_active_tasks_per_tenant", 50)
    sec.setdefault("max_active_tasks_global", 200)
    sec.setdefault("create_rate_per_minute_per_tenant", 60)
    # Spawn-depth budget: how many generations of worker-created tasks are
    # allowed (operator/chat-created tasks are depth 0; a worker running a
    # depth-0 task creates depth-1 tasks, etc). Caps self-replicating boards
    # (June 2026 worker fork-bomb). 0 disables the cap.
    sec.setdefault("max_spawn_depth", 2)
    # Plan B: mesh delegation gates. Defaults to operator-only mesh-eligible
    # creation and inbound mesh delegations executing immediately (legacy
    # mesh inbound flow). Operators can flip these to surface mesh routing
    # to the agent / hold inbound for approval respectively.
    sec.setdefault("mesh_eligible_agent_creation", False)
    sec.setdefault("surface_mesh_inbound", False)
    sec.setdefault("triage_enabled", True)
    # Strict execution rollout controls (no-excuses mode).
    sec.setdefault("strict_mode_enabled", False)
    sec.setdefault("strict_shadow_mode_enabled", False)
    sec.setdefault("strict_legacy_outcome_mapping", True)
    sec.setdefault("coordinator_mode_enabled", True)
    sec.setdefault("coordinator_min_batch_size", 2)
    sec.setdefault("coordinator_allow_read_probe", False)
    # Kanban webhook subscriptions: http allowed only for loopback unless true.
    sec.setdefault("allow_http_webhooks", False)
    sec.setdefault("coordinator_notify_desktop", True)
    sec.setdefault("coordinator_max_tool_rounds_bonus", 15)
    sec.setdefault("coordinator_poll_idle_rounds", 8)
    sec.setdefault("coordinator_auto_repair", False)
    sec.setdefault("max_auto_repair_per_task", 2)
    sec.setdefault("coordinator_notify_on_blocked", True)
    # Grain-reframe Stage B (default OFF): promote the native claim-scoped result tool
    # (kanban_post_summary) to the PRIMARY completion handoff channel. When on, a deliberate
    # tool submission that stands in for a missing/truncated trailing fence is classified as a
    # first-class completion (handoff_source="tool_submit") rather than salvage, the completion
    # contract teaches the tool as preferred, the tool is auto-stamped into worker required_tools,
    # and the mandatory close turn asks the worker to call the tool. The trailing kanban-result
    # fence, salvage, and ledger synthesis all remain underneath as backups for models that do
    # not call tools natively. Flag-off reproduces the exact pre-Stage-B behavior.
    sec.setdefault("native_result_handoff", False)

    raw_conc = sec.get("concurrency")
    if not isinstance(raw_conc, dict):
        sec["concurrency"] = dict(_KANBAN_DEFAULT_CONCURRENCY)
    else:
        merged = dict(_KANBAN_DEFAULT_CONCURRENCY)
        for k, v in raw_conc.items():
            try:
                merged[str(k)] = max(0, int(v))
            except (TypeError, ValueError):
                continue
        sec["concurrency"] = merged

    return sec


def get_kernel_config() -> dict:
    """Return [kernel] section with safe defaults (docs/kernel_spec.md §8).

    ``enabled`` is the cutover flag: when true, every surface (chat, subagent,
    kanban-worker, quick-panel, dashboard) routes through the extracted kernel
    (core/loop.py) instead of the legacy ``execute_raw_agent``.

    Default False: the Phase 5 cutover was reverted after the soak surfaced a
    salvage gap — the kernel's content salvage (core/model.py) only handles the
    Hermes/XML ``<tool_call>`` shape, while legacy ``agent_loop_tool_parse.py``
    handles five formats (incl. Gemma channel-style ``call:name{args}``). Re-flip
    to True only after the kernel salvage covers the same formats. Opt in early
    with ``[kernel] enabled = true``.
    """
    sec = (get_config().get("kernel") or {}).copy()
    sec.setdefault("enabled", False)  # reverted Phase 5 cutover (salvage gap)
    return sec


def get_council_config() -> dict:
    """Return [council] section with safe defaults (Slice 1: manual convene only)."""
    sec = (get_config().get("council") or {}).copy()
    sec.setdefault("enabled", False)
    sec.setdefault("mode", "off")  # off | on_request | auto_complex | always
    sec.setdefault("daily_council_cap", 10)
    sec.setdefault("hourly_council_cap", 4)
    sec.setdefault("max_rounds", 1)
    sec.setdefault("max_researcher_attempts", 2)
    sec.setdefault("default_seat_timeout_seconds", 900)
    sec.setdefault("show_transcript_in_chat", False)
    sec.setdefault("council_batch_deadline_seconds", 3600)
    return sec


def get_knowledge_config() -> dict:
    """Return [knowledge] section with safe defaults (Phase 6 automation)."""
    sec = (get_config().get("knowledge") or {}).copy()
    sec.setdefault("automation_enabled", False)
    sec.setdefault("watch_folders_enabled", False)
    sec.setdefault("heartbeat_stale_refresh_enabled", False)
    sec.setdefault("auto_refresh_stale", True)
    sec.setdefault("heartbeat_interval_minutes", 360)
    sec.setdefault("max_corpora_per_run", 10)
    sec.setdefault("max_concurrent_checks", 2)
    sec.setdefault("watch_debounce_seconds", 30)
    sec.setdefault("watch_ignore_hidden", True)
    retrieval = sec.get("retrieval")
    if not isinstance(retrieval, dict):
        retrieval = {}
    retrieval.setdefault("max_sub_queries", 3)
    retrieval.setdefault("ask_max_hits", 16)
    retrieval.setdefault("tool_max_hits", 12)
    retrieval.setdefault("inject_max_hits", 8)
    retrieval.setdefault("mmr_per_source_cap", 3)
    retrieval.setdefault("min_fused_score", 0.05)
    retrieval.setdefault("ask_min_fused_score", 0.10)
    retrieval.setdefault("lane_weight_semantic", 1.0)
    retrieval.setdefault("lane_weight_fts_or", 0.88)
    retrieval.setdefault("lane_weight_topic", 0.92)
    retrieval.setdefault("lane_weight_text_fallback", 0.42)
    retrieval.setdefault("allow_text_fallback", True)
    sec["retrieval"] = retrieval
    sec.setdefault("digest_llm_read_timeout_seconds", 120)
    themes = sec.get("themes")
    if not isinstance(themes, dict):
        themes = {}
    themes.setdefault("semantic_themes_enabled", True)
    themes.setdefault("min_clusters", 3)
    themes.setdefault("max_clusters", 12)
    themes.setdefault("min_chunks", 8)
    themes.setdefault("sample_cap", 400)
    themes.setdefault("label_top_terms", 3)
    themes.setdefault("representatives_per_theme", 5)
    themes.setdefault("min_coherence", 0.15)
    sec["themes"] = themes
    entities = sec.get("entities")
    if not isinstance(entities, dict):
        entities = {}
    entities.setdefault("entities_enabled", True)
    entities.setdefault("min_chunks", 8)
    entities.setdefault("sample_cap", 400)
    entities.setdefault("max_entities", 40)
    entities.setdefault("min_mentions", 2)
    entities.setdefault("min_salience", 0.0)
    entities.setdefault("entity_ngram_max", 3)
    entities.setdefault("cooccurrence_pair_cap", 8000)
    entities.setdefault("max_claims", 30)
    entities.setdefault("claims_per_entity", 2)
    entities.setdefault("claim_max_chars", 200)
    entities.setdefault("claim_phrasing_llm", False)
    sec["entities"] = entities
    contradictions = sec.get("contradictions")
    if not isinstance(contradictions, dict):
        contradictions = {}
    contradictions.setdefault("contradictions_enabled", True)
    contradictions.setdefault("max_contradictions", 20)
    contradictions.setdefault("min_confidence", 0.0)
    contradictions.setdefault("allow_same_source", False)
    sec["contradictions"] = contradictions
    return sec


def get_contradictions_config() -> dict:
    """Return [knowledge.contradictions] with safe defaults (Phase 7b)."""
    from utils.knowledge_contradictions import get_contradictions_config as _contradictions_cfg

    return _contradictions_cfg()


def get_themes_config() -> dict:
    """Return [knowledge.themes] with safe defaults (Phase 6 semantic discovery)."""
    from utils.knowledge_themes import get_themes_config as _themes_cfg

    return _themes_cfg()


def get_entities_config() -> dict:
    """Return [knowledge.entities] with safe defaults (Phase 7a entity/claim layer)."""
    from utils.knowledge_entities import get_entities_config as _entities_cfg

    return _entities_cfg()


def get_summarization_config() -> dict:
    """Return [summarization] section: enabled, trigger_fraction, keep_messages, model, base_url, api_key."""
    return get_config().get("summarization") or {}


def get_agent_loop_tool_context_config() -> dict:
    """Return [agent_loop_tool_context] with defaults (parity: features off until enabled)."""
    from utils.agent_loop_tool_context import coerce_agent_loop_tool_context_config

    raw = get_config().get("agent_loop_tool_context")
    section = dict(raw) if isinstance(raw, dict) else {}
    return coerce_agent_loop_tool_context_config(section)


def get_consolidation_config() -> dict:
    """Return [consolidation] section: enabled, lookback_days, merge_to_memory, model, base_url, api_key."""
    sec = dict(get_config().get("consolidation") or {})
    sec.setdefault("personal_noticing_enabled", True)
    sec.setdefault("personal_pattern_observations_enabled", True)
    sec.setdefault("connections_journal_max_bytes", 65536)
    sec.setdefault("connections_preload_max_chars", 4096)
    sec.setdefault("vault_noticing_enabled", True)
    sec.setdefault("vault_noticing_max_chars", 4096)
    sec.setdefault("vault_noticing_max_notes", 8)
    return sec


def get_user_model_config() -> dict:
    """Return [user_model] section: background proposal routing and fatigue caps."""
    sec = dict(get_config().get("user_model") or {})
    sec.setdefault("background_proposals_enabled", True)
    sec.setdefault("max_proposals_per_cycle", 3)
    sec.setdefault("dedupe_against_observations", True)
    return sec


def get_text_evolution_config() -> dict:
    """Return [text_evolution] section with defaults for the GEPA skill optimizer."""
    sec = (get_config().get("text_evolution") or {}).copy()
    sec.setdefault("enabled", True)
    sec.setdefault("min_interval_hours", 24)
    sec.setdefault("min_eval_tasks", 3)
    sec.setdefault("max_metric_calls_per_day", 50)
    sec.setdefault("max_pending_proposals", 10)
    sec.setdefault("allow_on_battery", False)
    sec.setdefault("eval_iteration_budget_total", 15)
    sec.setdefault("eval_tasks_path", "")
    sec.setdefault("eval_model", "")
    sec.setdefault("optimizer_model", "")
    sec.setdefault("base_url", "")
    sec.setdefault("api_key", "")
    return sec


def get_curiosity_config() -> dict:
    """Return [curiosity] section: enabled, prefetch_enabled, feed_matching_enabled, model, base_url, api_key."""
    return get_config().get("curiosity") or {}


def get_agent_plane_config() -> dict:
    """Return [agent_plane] section defaults for unified proactive context assembly."""
    sec = (get_config().get("agent_plane") or {}).copy()
    sec.setdefault("enabled", True)
    sec.setdefault("max_chars", 64_000)
    sec.setdefault("drive_dashboard_limit", 5)
    sec.setdefault("enable_turn_log", True)
    sec.setdefault("turn_log_max_kb", 128)
    sec.setdefault("turn_log_max_lines", 2_000)
    sec.setdefault("cron_char_budget", 12_000)
    sec.setdefault("cron_include_user_md", False)
    sec.setdefault("cron_include_insights", False)
    sec.setdefault("include_agent_turns_tail", True)
    # Phase 2: idle in-chat tick
    sec.setdefault("idle_tick_minutes", 5.0)
    # idle_tick_hot_thread_minutes: optional; when omitted, idle_tick.get_idle_tick_config uses 2× idle_tick_minutes.
    sec.setdefault("idle_tick_max_per_day", 24)
    sec.setdefault("idle_tick_cooldown_seconds", 300)
    # Phase 2: open loops
    sec.setdefault("include_open_loops", True)
    return sec


def merge_drives_defaults(raw: dict | None) -> dict:
    """Apply [drives] defaults to a raw section dict (single source for drive_engine + accessors)."""
    cfg = (raw or {}).copy()
    cfg.setdefault("enabled", True)
    cfg.setdefault("discovery_model", "")
    cfg.setdefault("discovery_use_main_llm", True)
    cfg.setdefault("base_url", "")
    cfg.setdefault("api_key", "")
    cfg.setdefault("escalation_step", 0.1)
    cfg.setdefault("max_active_drives", 5)
    cfg.setdefault("visibility_threshold", 0.3)
    cfg.setdefault("urgency_threshold", 0.6)
    cfg.setdefault("critical_threshold", 0.9)
    cfg.setdefault("max_age_days", 14)
    cfg.setdefault("idle_trigger_minutes", 8)
    cfg.setdefault("idle_trigger_grace_minutes", 1.0)
    cfg.setdefault("action_lease_ttl_seconds", 900)
    cfg.setdefault("blocked_retry_minutes", 30)
    cfg.setdefault("resolution_require_evidence", True)
    cfg.setdefault("progressed_autoresolve_streak", 3)
    cfg.setdefault("autonomy_mode", "bounded")
    cfg.setdefault("autonomy_kill_switch", False)
    cfg.setdefault("aggressive_allowlist", [])
    cfg.setdefault("observe_only", False)
    cfg.setdefault("max_proactive_actions_per_hour", 6)
    cfg.setdefault("max_proactive_actions_per_day", 40)
    cfg.setdefault("max_proactive_tokens_per_day", 25000)
    cfg.setdefault("max_tool_calls_per_cycle", 3)
    cfg.setdefault("proactive_allowlist", [
        "search_web",
        "search_web_public",
        "read_webpage",
        "read_file",
        "read_file_chunk",
        "query_knowledge",
        "append_to_memory",
        "write_session_state",
        "skill_manage",
        "create_plugin",
        "save_to_file",
        "write_text_file",
        "list_drives",
        "resolve_drive",
        "dismiss_drive",
    ])
    return cfg


def get_drives_config() -> dict:
    """Return [drives] section: motivation engine tuning."""
    cfg = merge_drives_defaults(get_config().get("drives"))
    cfg.setdefault("discovery_strict_grounding", False)
    return cfg


def get_utility_config() -> dict:
    """Return [utility] section: enabled, model_repo, completion critic toggles."""
    from persistence import DEFAULT_UTILITY_MODEL_REPO, LEGACY_UTILITY_MODEL_REPOS

    sec = dict(get_config().get("utility") or {})
    sec.setdefault("enabled", True)
    repo = str(sec.get("model_repo") or "").strip()
    if repo in LEGACY_UTILITY_MODEL_REPOS:
        sec["model_repo"] = DEFAULT_UTILITY_MODEL_REPO
    sec.setdefault("model_repo", DEFAULT_UTILITY_MODEL_REPO)
    sec.setdefault("completion_critic_enabled", False)
    sec.setdefault("completion_critic_max_retries", 1)
    sec.setdefault("embedding_enabled", True)
    sec.setdefault("embedding_model_repo", "mlx-community/bge-small-en-v1.5-bf16")
    sec.setdefault("embedding_query_prefix", "")
    sec.setdefault("embedding_doc_prefix", "")
    sec.setdefault("embedding_chunk_chars", 1800)
    sec.setdefault("embedding_chunk_overlap", 180)
    return sec


def get_mesh_config() -> dict:
    """Return [mesh] section with defaults for autonomous social loop and limits."""
    sec = (get_config().get("mesh") or {}).copy()
    sec.setdefault("autonomous_social_enabled", True)
    sec.setdefault("mesh_rapport_memory_enabled", True)
    sec.setdefault("mesh_rapport_digest_in_main_chat", True)
    sec.setdefault("mesh_rapport_context_max_chars", 1800)
    sec.setdefault("mesh_rapport_digest_max_chars", 1200)
    sec.setdefault("mesh_rapport_digest_style", "normal")
    sec.setdefault("mesh_rapport_global_max_bytes", 40 * 1024 * 1024)
    sec.setdefault("mesh_rapport_per_record_max_bytes", 1024 * 1024)
    sec.setdefault("mesh_rapport_stale_days", 30)
    sec.setdefault("mesh_rapport_export_max_records", 200)
    sec.setdefault("mesh_rapport_export_max_bytes", 4 * 1024 * 1024)
    sec.setdefault("mesh_rapport_export_per_minute", 30)
    sec.setdefault("idle_conversation_interval_seconds", 900)
    sec.setdefault("daily_autonomous_conversation_cap", 20)
    sec.setdefault("autonomous_llm_calls_per_hour", 24)
    sec.setdefault("max_followups_per_social_conversation", 2)
    sec.setdefault("user_active_pause_debounce_ms", 3000)
    sec.setdefault("long_idle_nudge_minutes", 120)
    sec.setdefault("mesh_llm_max_seconds", 120)
    sec.setdefault("pending_result_max_total", 20)
    sec.setdefault("pending_result_max_per_peer", 5)
    sec.setdefault("mesh_logical_message_dedupe_enabled", True)
    sec.setdefault("mesh_logical_message_dedupe_ttl_seconds", 259200)
    sec.setdefault("mesh_logical_message_dedupe_max_entries", 8192)
    # Large enough for multi-thousand-file transfer manifests after encryption wrapper.
    sec.setdefault("message_max_bytes", 4 * 1024 * 1024)
    sec.setdefault("pending_result_max_chars", 8000)
    sec.setdefault("handoff_critic_enabled", False)
    sec.setdefault("handoff_envelope_max_json_chars", 2000)
    # Outbound contact_peer(wait_for_result=true) waits for task_result; peer runs full agent loop (often >120s).
    sec.setdefault("contact_peer_default_timeout_seconds", 300)
    sec.setdefault("contact_peer_max_timeout_seconds", 3600)
    sec.setdefault("file_transfer_enabled", True)
    sec.setdefault("file_transfer_max_bytes", 100 * 1024 * 1024 * 1024)
    sec.setdefault("file_transfer_max_files", 2500)
    sec.setdefault("file_transfer_offer_ttl_seconds", 3600)
    sec.setdefault("file_transfer_serve_chunk_size", 256 * 1024)
    sec.setdefault("file_transfer_max_concurrent_transfers", 4)
    # Per-IP sliding window for GET /mesh/file_transfers/serve (receiver may fetch many small files).
    sec.setdefault("file_transfer_serve_rate_per_minute", 8000)
    sec.setdefault("file_transfer_max_walk_depth", 64)
    sec.setdefault("file_transfer_skip_dotfiles", True)
    # Extra Mesh Activity lines for Quick Panel file sends (prepare abandoned / offer committed).
    sec.setdefault("file_transfer_audit_quick_panel", False)
    # Optional override for file-transfer offers (empty = auto-detect LAN IP).
    sec.setdefault("advertise_host", "")
    # When true, mesh_tools peer resolution allows prefix hostname/nickname matching.
    sec.setdefault("peer_name_prefix_match", False)
    # Peer "learning" proposals (Phase B): inbound peer_proposal messages are
    # validated and stored for user review, but only when this flag is true.
    # Staged rollout: ships disabled; flip to true once the UI is available.
    sec.setdefault("peer_proposals_enabled", False)
    # Rate-limit inbound proposals per peer (sliding 1h window).
    sec.setdefault("max_inbound_proposals_per_peer_per_hour", 20)
    return sec


def save_config(config: dict) -> None:
    """Write config dict to mugi_config.toml. Updates cache."""
    global _cached_config, _last_reload_sig
    from persistence import _save_agent_config
    _save_agent_config(config)
    _cached_config = config
    _last_reload_sig = _config_sig()
    _sync_runtime_debug_state(config)


def get_messaging_config() -> dict:
    """
    Return the [messaging] section from mugi_config.toml.
    Example: {"enabled": true, "rate_limit_seconds": 5, "discord": {...}}.
    """
    config = get_config()
    return (config.get("messaging") or {}).copy()


def get_session_search_config() -> dict:
    """
    Return the [session_search] section from mugi_config.toml.
    exclude_background_threads: when True, FTS session search drops hits whose thread_id
    starts with heartbeat_, background_heartbeat, or drive_discovery_.
    """
    config = get_config()
    sec = (config.get("session_search") or {}).copy()
    sec.setdefault("exclude_background_threads", True)
    sec.setdefault("semantic_search_enabled", False)
    sec.setdefault("hybrid_search_enabled", True)
    sec.setdefault("semantic_embedding_dimensions", 256)
    sec.setdefault("semantic_candidate_limit", 2000)
    sec.setdefault("semantic_max_hits", 25)
    sec.setdefault("semantic_query_min_chars", 3)
    sec.setdefault("semantic_embed_batch_max", 64)
    sec.setdefault("semantic_embed_max_chunks_per_call", 256)
    sec.setdefault("semantic_rrf_k", 60)
    sec.setdefault("semantic_ann_enabled", True)
    candidate = int(sec.get("semantic_candidate_limit", 2000) or 2000)
    sec.setdefault("semantic_ann_k", min(200, candidate))
    sec.setdefault("semantic_ann_overfetch", 3)
    return sec


def get_search_config() -> dict:
    """
    Return the [search] section from mugi_config.toml with defaults.
    When use_local_docker is True, effective base_url is http://127.0.0.1:{port}.
    """
    config = get_config()
    s = (config.get("search") or {}).copy()
    s.setdefault("use_local_docker", True)
    s.setdefault("base_url", "http://192.168.1.111:8888")
    s.setdefault("port", 8888)
    raw_fallback = s.get("public_fallback_url") or ""
    s.setdefault("public_fallback_url", "https://searx.party")
    if raw_fallback.strip() == "https://searx.be":
        s["public_fallback_url"] = "https://searx.party"
    return s


_PLAYWRIGHT_FALLBACK_HOST_MAX = 200
_PLAYWRIGHT_FALLBACK_RULE_LEN = 200


def get_web_config() -> dict:
    """
    Return the [web] section from mugi_config.toml with sanitized defaults.

    ``playwright_fallback_hosts`` is a list of domain rules (e.g. ``twitter.com``).
    Hostnames match if they equal the rule or are a subdomain of it (``mobile.twitter.com``
    matches ``twitter.com``). Optional leading ``*.`` is stripped. At most
    ``_PLAYWRIGHT_FALLBACK_HOST_MAX`` entries; each rule is length-capped.
    """
    config = get_config()
    w = ((config.get("web") or {}).copy() if isinstance(config.get("web"), dict) else {})
    raw = w.get("playwright_fallback_hosts")
    if not isinstance(raw, list):
        raw = []
    out: list[str] = []
    seen: set[str] = set()
    for x in raw:
        if not isinstance(x, str):
            continue
        s = x.strip().lower()
        if s.startswith("*."):
            s = s[2:].strip()
        if not s or len(s) > _PLAYWRIGHT_FALLBACK_RULE_LEN:
            continue
        if s in seen:
            continue
        seen.add(s)
        out.append(s)
        if len(out) >= _PLAYWRIGHT_FALLBACK_HOST_MAX:
            break
    w["playwright_fallback_hosts"] = out
    return w


def get_web_act_config() -> dict:
    """Return the [web_act] section from mugi_config.toml with sanitized defaults."""
    config = get_config()
    sec = ((config.get("web_act") or {}).copy() if isinstance(config.get("web_act"), dict) else {})
    sec.setdefault("enabled", True)
    sec.setdefault("max_script_seconds", 120)
    sec.setdefault("max_script_bytes", 256_000)
    sec.setdefault("max_runs_per_hour_per_scope", 30)
    sec.setdefault("max_stdout_chars", 8000)
    sec.setdefault("require_playwright", True)
    sec.setdefault("allowed_hosts", [])
    sec.setdefault("reflection_enabled", False)
    sec.setdefault("reflection_max_chars", 600)
    sec.setdefault("max_scope_bytes", 524_288_000)
    sec.setdefault("max_run_age_days", 30)
    sec.setdefault("max_runs_per_scope", 50)
    sec.setdefault("retention_enabled", False)
    try:
        sec["max_script_seconds"] = max(10, int(sec.get("max_script_seconds") or 120))
    except (TypeError, ValueError):
        sec["max_script_seconds"] = 120
    try:
        sec["max_script_bytes"] = max(1024, int(sec.get("max_script_bytes") or 256_000))
    except (TypeError, ValueError):
        sec["max_script_bytes"] = 256_000
    try:
        sec["max_runs_per_hour_per_scope"] = max(1, int(sec.get("max_runs_per_hour_per_scope") or 30))
    except (TypeError, ValueError):
        sec["max_runs_per_hour_per_scope"] = 30
    try:
        sec["max_stdout_chars"] = max(500, int(sec.get("max_stdout_chars") or 8000))
    except (TypeError, ValueError):
        sec["max_stdout_chars"] = 8000
    sec["enabled"] = bool(sec.get("enabled", True))
    sec["require_playwright"] = bool(sec.get("require_playwright", True))
    raw_hosts = sec.get("allowed_hosts")
    if isinstance(raw_hosts, list):
        sec["allowed_hosts"] = [str(h).strip() for h in raw_hosts if str(h).strip()]
    else:
        sec["allowed_hosts"] = []
    sec["reflection_enabled"] = bool(sec.get("reflection_enabled", False))
    try:
        sec["reflection_max_chars"] = max(120, int(sec.get("reflection_max_chars") or 600))
    except (TypeError, ValueError):
        sec["reflection_max_chars"] = 600
    try:
        sec["max_scope_bytes"] = max(0, int(sec.get("max_scope_bytes") or 524_288_000))
    except (TypeError, ValueError):
        sec["max_scope_bytes"] = 524_288_000
    try:
        sec["max_run_age_days"] = max(0, int(sec.get("max_run_age_days") or 30))
    except (TypeError, ValueError):
        sec["max_run_age_days"] = 30
    try:
        sec["max_runs_per_scope"] = max(0, int(sec.get("max_runs_per_scope") or 50))
    except (TypeError, ValueError):
        sec["max_runs_per_scope"] = 50
    sec["retention_enabled"] = bool(sec.get("retention_enabled", False))
    return sec


def get_memory_config() -> dict:
    """
    Return the [memory] section from mugi_config.toml.
    Defaults: soft_cap_characters=18000, hard_cap_characters=24000, archive_when_exceeds=true.
    Also merges ``memory_search_*`` fusion keys (see ``utils.memory_search_fusion``).
    """
    from utils.memory_search_fusion import apply_fusion_config_defaults

    config = get_config()
    mem = (config.get("memory") or {}).copy()
    mem.setdefault("soft_cap_characters", 18000)
    mem.setdefault("hard_cap_characters", 24000)
    mem.setdefault("archive_when_exceeds", True)
    mem.setdefault("memory_fts_enabled", True)
    mem.setdefault("memory_fts_max_candidates", 80)
    mem.setdefault("memory_fts_fallback_min_hits", 4)
    mem.setdefault("memory_fts_mode", "and")
    apply_fusion_config_defaults(mem)
    return mem


def get_allowed_dirs() -> list[str]:
    """Return expanded list of user-configured allowed directories (from [paths].allowed_dirs).

    Returns absolute paths; empty list if none configured.
    """
    config = get_config()
    raw = (config.get("paths") or {}).get("allowed_dirs", [])
    if not isinstance(raw, list):
        return []
    result = []
    for d in raw:
        if isinstance(d, str) and d.strip():
            result.append(os.path.abspath(os.path.expanduser(d.strip())))
    return result


def get_heartbeat_config() -> dict:
    """
    Return the [heartbeat] section from mugi_config.toml.
    Defaults: standard_interval_minutes=30, memory_maintenance_interval_minutes=360,
    growth_check_interval_minutes=1440, consciousness_interval_minutes=360,
    consolidation_interval_minutes=720 (12 hours),
    curiosity_interval_minutes=240 (4 hours),
    research_prefetch_interval_minutes=720 (12 hours),
    collector_interval_minutes=0 (disabled by default),
    feeds_check_interval_minutes=0 (disabled by default), notification_sound=true.
    Intervals of 0 mean that task is disabled.
    """
    config = get_config()
    hb = (config.get("heartbeat") or {}).copy()
    hb.setdefault("standard_interval_minutes", 30)
    hb.setdefault("memory_maintenance_interval_minutes", 360)
    hb.setdefault("growth_check_interval_minutes", 1440)
    hb.setdefault("consciousness_interval_minutes", 360)
    hb.setdefault("consolidation_interval_minutes", 720)
    hb.setdefault("curiosity_interval_minutes", 240)
    hb.setdefault("research_prefetch_interval_minutes", 720)
    hb.setdefault("drive_cycle_interval_minutes", 180)
    hb.setdefault("drive_action_cycle_interval_minutes", 30)
    hb.setdefault("notification_sound", True)
    hb.setdefault("collector_interval_minutes", 0)
    hb.setdefault("feeds_check_interval_minutes", 0)
    hb.setdefault("skip_gate_enabled", True)
    hb.setdefault("skip_gate_debug", False)
    hb.setdefault("skip_gate_tension_threshold", 0.6)
    return hb


def _read_app_pref(key: str, default=None):
    """Best-effort read from ~/.mugi/app_prefs.json (user-facing prefs).

    Kept local to avoid a hard dependency on ``agent_loop_config`` during early
    import cycles. Silent default on any error.
    """
    try:
        from utils.app_prefs_store import load_app_prefs_cached
        prefs = load_app_prefs_cached()
        return prefs.get(key, default)
    except Exception:
        return default


def get_dreaming_config() -> dict:
    """Return [dreaming] section: scored memory promotion & DREAMS.md audit.

    Defaults: enabled=false (opt-in), promote_threshold=0.60, utility_tag_enabled=true,
    rem_cadence_days=3, dreams_file_max_kb=256. SwiftUI-visible toggles
    (``dreaming_enabled``, ``dreaming_promote_threshold``) in app_prefs override
    the TOML defaults when present.
    """
    sec = (get_config().get("dreaming") or {}).copy()
    sec.setdefault("enabled", False)
    sec.setdefault("promote_threshold", 0.60)
    sec.setdefault("utility_tag_enabled", True)
    sec.setdefault("rem_cadence_days", 3)
    sec.setdefault("dreams_file_max_kb", 256)
    sec.setdefault("max_candidates_per_cycle", 25)

    pref_enabled = _read_app_pref("dreaming_enabled", None)
    if isinstance(pref_enabled, bool):
        sec["enabled"] = pref_enabled
    pref_threshold = _read_app_pref("dreaming_promote_threshold", None)
    if isinstance(pref_threshold, (int, float)):
        try:
            sec["promote_threshold"] = max(0.0, min(1.0, float(pref_threshold)))
        except (TypeError, ValueError):
            pass
    return sec


def get_active_memory_config() -> dict:
    """Return [active_memory] section: pre-reply memory sub-agent.

    Defaults: enabled=true, prompt_style='balanced', timeout_ms=6000,
    max_facts=5, max_chars=800. SwiftUI-visible toggles
    (``active_memory_enabled``, ``active_memory_prompt_style``) in app_prefs
    override the TOML defaults when present.
    """
    sec = (get_config().get("active_memory") or {}).copy()
    sec.setdefault("enabled", True)
    sec.setdefault("prompt_style", "balanced")
    sec.setdefault("timeout_ms", 6000)
    sec.setdefault("max_facts", 5)
    sec.setdefault("max_chars", 800)

    pref_enabled = _read_app_pref("active_memory_enabled", None)
    if isinstance(pref_enabled, bool):
        sec["enabled"] = pref_enabled
    pref_style = _read_app_pref("active_memory_prompt_style", None)
    if isinstance(pref_style, str) and pref_style.strip().lower() in {
        "balanced",
        "precision",
        "recall",
    }:
        sec["prompt_style"] = pref_style.strip().lower()
    return sec


def get_webhooks_config() -> dict:
    """Return [webhooks] section: rate-limit, per-source rendering modes.

    Secrets are NOT read here — they live exclusively in Keychain. The
    ``webhooks_enabled`` master toggle lives in ``app_prefs.json`` (SwiftUI).
    """
    sec = (get_config().get("webhooks") or {}).copy()
    sec.setdefault("rate_limit_per_min", 10)
    sec.setdefault("default_mode", "summary_template")
    sec.setdefault("per_source", {})
    return sec


def get_task_ledger_config() -> dict:
    """Return [task_ledger] section: retention knobs."""
    sec = (get_config().get("task_ledger") or {}).copy()
    sec.setdefault("retention_days", 7)
    sec.setdefault("max_entries", 20_000)
    return sec


def get_delivery_queue_config() -> dict:
    """Return [delivery_queue] section: max_attempts, backoff knobs."""
    sec = (get_config().get("delivery_queue") or {}).copy()
    sec.setdefault("max_attempts", 5)
    sec.setdefault("tick_seconds", 5)
    return sec


def get_timers_config() -> dict:
    """Return [timers] section: agent-accessible short timers (Phase 2b §1)."""
    sec = (get_config().get("timers") or {}).copy()
    sec.setdefault("enabled", False)
    sec.setdefault("drain_tick_seconds", 30)
    sec.setdefault("max_pending_per_thread", 25)
    return sec


def get_outbound_webhooks_config() -> dict:
    """Return [hooks.outbound] section: outbound webhook delivery (Phase 2b §2)."""
    sec = ((get_config().get("hooks") or {}).get("outbound") or {}).copy()
    sec.setdefault("enabled", False)
    sec.setdefault("allow_loopback_http", True)
    sec.setdefault("connect_timeout_seconds", 5)
    sec.setdefault("read_timeout_seconds", 10)
    sec.setdefault("max_response_bytes", 64 * 1024)
    return sec


def get_file_watcher_config() -> dict:
    """Return [file_watcher] section: filesystem-event proactive triggers (Phase 2b §4)."""
    sec = (get_config().get("file_watcher") or {}).copy()
    sec.setdefault("enabled", False)
    sec.setdefault("max_rules", 8)
    sec.setdefault("default_debounce_seconds", 5)
    sec.setdefault("ignore_hidden", True)
    return sec


def get_calendar_proximity_config() -> dict:
    """Return [calendar_proximity] section: calendar-proximity wakes (Phase 2b §5)."""
    sec = (get_config().get("calendar_proximity") or {}).copy()
    sec.setdefault("enabled", False)
    sec.setdefault("poll_seconds", 300)
    sec.setdefault("prewarm_cache_ttl_seconds", 15 * 60)
    sec.setdefault("target_thread_id", "default")
    return sec


def get_ledger_reflection_config() -> dict:
    """Return [ledger_reflection] section: weekly self-review (Phase 2b §6)."""
    sec = (get_config().get("ledger_reflection") or {}).copy()
    sec.setdefault("enabled", False)
    sec.setdefault("weekday", 6)  # Sunday
    sec.setdefault("hour", 3)
    sec.setdefault("lookback_days", 7)
    sec.setdefault("stale_producer_threshold_errors", 5)
    sec.setdefault("stale_producer_error_ratio", 0.4)
    return sec


def _legacy_email_account_id(imap_host: str, imap_user: str) -> str:
    host = (imap_host or "").strip().lower()
    user = (imap_user or "").strip().lower()
    digest = hashlib.sha1(f"{host}\0{user}".encode("utf-8")).hexdigest()
    return f"email_{digest[:12]}"


def _coerce_email_account(raw: object) -> dict | None:
    if not isinstance(raw, dict):
        return None
    account_id = str(raw.get("id") or "").strip()
    host = str(raw.get("imap_host") or "").strip()
    user = str(raw.get("imap_user") or "").strip()
    if not account_id:
        account_id = _legacy_email_account_id(host, user)
    if not host and not user:
        return None
    port_val = raw.get("imap_port")
    try:
        port = int(port_val) if port_val is not None else 993
    except Exception:
        port = 993
    if port < 1 or port > 65535:
        port = 993
    label = str(raw.get("label") or "").strip()
    if not label:
        label = user or host or account_id
    provider_hint = str(raw.get("provider_hint") or "").strip()
    enabled = bool(raw.get("enabled", True))
    return {
        "id": account_id,
        "label": label,
        "imap_host": host,
        "imap_port": port,
        "imap_user": user,
        "enabled": enabled,
        "provider_hint": provider_hint,
    }


def normalize_email_config(email_section: dict | None) -> tuple[dict, bool]:
    """Normalize ``[email]`` config into the multi-account schema.

    Returns ``(normalized, changed)`` where normalized has:
    ``schema_version``, ``accounts`` and ``default_account_id``.
    """
    incoming = email_section if isinstance(email_section, dict) else {}
    accounts_in = incoming.get("accounts")
    accounts_out: list[dict] = []
    seen_ids: set[str] = set()

    if isinstance(accounts_in, list):
        for item in accounts_in:
            acc = _coerce_email_account(item)
            if not acc:
                continue
            account_id = acc["id"]
            if account_id in seen_ids:
                continue
            seen_ids.add(account_id)
            accounts_out.append(acc)
    else:
        # One-time local transition from the old single-account shape.
        host = str(incoming.get("imap_host") or "").strip()
        user = str(incoming.get("imap_user") or "").strip()
        if host or user:
            fallback = _coerce_email_account(
                {
                    "id": incoming.get("id") or _legacy_email_account_id(host, user),
                    "label": incoming.get("label") or user or host,
                    "imap_host": host,
                    "imap_port": incoming.get("imap_port"),
                    "imap_user": user,
                    "enabled": incoming.get("enabled", True),
                    "provider_hint": incoming.get("provider_hint", ""),
                }
            )
            if fallback:
                accounts_out.append(fallback)

    default_id = str(incoming.get("default_account_id") or "").strip()
    if default_id and not any(a["id"] == default_id for a in accounts_out):
        default_id = ""
    if not default_id and accounts_out:
        enabled_accounts = [a for a in accounts_out if a.get("enabled", True)]
        default_id = (enabled_accounts[0]["id"] if enabled_accounts else accounts_out[0]["id"])

    normalized = {
        "schema_version": 2,
        "accounts": accounts_out,
        "default_account_id": default_id,
    }
    changed = normalized != incoming
    return normalized, changed


def get_email_accounts() -> list[dict]:
    """Return normalized IMAP account rows from ``[email]`` config."""
    config = get_config()
    email = config.get("email") if isinstance(config, dict) else {}
    normalized, _ = normalize_email_config(email if isinstance(email, dict) else {})
    return list(normalized.get("accounts") or [])


def get_default_email_account() -> dict:
    """Return the default enabled email account (or first account, or {})."""
    config = get_config()
    email = config.get("email") if isinstance(config, dict) else {}
    normalized, _ = normalize_email_config(email if isinstance(email, dict) else {})
    accounts = list(normalized.get("accounts") or [])
    default_id = str(normalized.get("default_account_id") or "").strip()
    if default_id:
        for acc in accounts:
            if str(acc.get("id") or "") == default_id:
                return acc.copy()
    for acc in accounts:
        if bool(acc.get("enabled", True)):
            return acc.copy()
    return accounts[0].copy() if accounts else {}


def get_email_config() -> dict:
    """
    Back-compat helper: return default account as flat IMAP config.
    Keys: imap_host, imap_port (default 993), imap_user, id, label.
    """
    account = get_default_email_account()
    if not account:
        return {"imap_port": 993}
    out = account.copy()
    out.setdefault("imap_port", 993)
    return out


def get_attachment_mode() -> str:
    """Return attachment resolution mode: ``dual`` (default), ``id_preferred``, or ``id_strict``.

    Configurable via ``[general].attachment_mode`` in mugi_config.toml or
    the ``MUGI_ATTACHMENT_MODE`` environment variable (env wins).
    """
    env = os.environ.get("MUGI_ATTACHMENT_MODE", "").strip().lower()
    if env in ("dual", "id_preferred", "id_strict"):
        return env
    config = get_config()
    mode = str((config.get("general") or {}).get("attachment_mode", "dual")).strip().lower()
    if mode in ("dual", "id_preferred", "id_strict"):
        return mode
    return "dual"
