import contextlib
import copy
import os
import re
import shutil
import tempfile
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import tomlkit
from filelock import FileLock

from otto.errors import OttoConfigError

_DEFAULT_OTTO_HOME = Path("~/.local/share/otto").expanduser()
_LEGACY_OTTO_HOME = Path("~/.otto").expanduser()

OTTO_HOME = Path(os.environ.get("OTTO_HOME", str(_DEFAULT_OTTO_HOME))).expanduser()
CONFIG_FILE = OTTO_HOME / "config.toml"
DATA_DIR = OTTO_HOME / "data"
LOGS_DIR = OTTO_HOME / "logs"
SESSIONS_DIR = OTTO_HOME / "sessions"

_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
_MISSING = object()
_CONFIG_LOCK_TIMEOUT = 10


def _atomic_write_text(path: Path, content: str) -> None:
    """Write *content* to *path* atomically using FileLock + os.replace."""
    path.parent.mkdir(parents=True, exist_ok=True)
    lock = FileLock(str(path) + ".lock", timeout=_CONFIG_LOCK_TIMEOUT)
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
    try:
        with lock:
            os.write(fd, content.encode("utf-8"))
            os.close(fd)
            fd = -1
            os.replace(tmp, str(path))
    except BaseException:
        with contextlib.suppress(OSError):
            if fd >= 0:
                os.close(fd)
            os.unlink(tmp)
        raise


class ConfigError(OttoConfigError):
    """Raised when configuration is invalid or incomplete."""


# ---------------------------------------------------------------------------
# Legacy dataclasses — kept for backward compatibility with telegram.py,
# daemon.py, chat.py, cli.py, web.py.  Will be removed in a future task.
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class TelegramBotConfig:
    alias: str
    token: str
    model: str | None = None


@dataclass(frozen=True)
class TelegramConfig:
    token: str
    owner_id: int | None
    allowed_users: list[int]
    bootstrap: str
    bots: list[TelegramBotConfig] = field(default_factory=list)


# ---------------------------------------------------------------------------
# New bot-first dataclasses
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class UserConfig:
    name: str
    telegram_id: int | None = None
    discord_id: int | None = None


@dataclass(frozen=True)
class BotAuthConfig:
    owner: str
    allowed_users: list[str]
    bootstrap: str = "disabled"


@dataclass(frozen=True)
class BotSkillsConfig:
    include_shared: bool = True
    allow_global_skills_in_sandbox: bool = False


@dataclass(frozen=True)
class ChannelConfig:
    type: str
    token: str | None = None
    application_id: str | None = None
    guild_id: str | None = None
    enabled: bool = True
    port: int | None = None


@dataclass(frozen=True)
class AgentConfig:
    model: str
    model_list: list[str] = field(default_factory=list)
    history_tool_results_keep: int = 4
    compaction_enabled: bool = True
    compaction_threshold: float = 0.8
    compaction_keep_recent_messages: int = 8
    delegation_timeout: int = 1800  # Default max seconds per delegated job (30 min)


@dataclass(frozen=True)
class WebConfig:
    enabled: bool = True
    host: str = "127.0.0.1"
    hostname: str | None = None
    port: int = 7070
    session_secret: str | None = None


@dataclass(frozen=True)
class AutonomyConfig:
    enabled: bool = False
    mode: str = "passive"
    pulse_minutes: int = 5


@dataclass(frozen=True)
class WorkspaceConfig:
    root: Path
    mode: str = "default"
    sandbox: str = "none"
    sandbox_network: bool = True


@dataclass(frozen=True)
class BotConfig:
    name: str
    model: str | None
    auth: BotAuthConfig
    workspace: WorkspaceConfig
    skills: BotSkillsConfig = field(default_factory=BotSkillsConfig)
    channels: list[ChannelConfig] = field(default_factory=list)
    thinking_default: str = "off"


@dataclass(frozen=True)
class Config:
    agent: AgentConfig
    log_level: str
    env_file: str | None
    users: list[UserConfig] = field(default_factory=list)
    bots: list[BotConfig] = field(default_factory=list)
    web: WebConfig = field(default_factory=WebConfig)
    autonomy: AutonomyConfig = field(default_factory=AutonomyConfig)
    acp_agents: dict[str, dict] | None = None

    # Legacy fields — kept for backward compat, will be removed in a future task.
    telegram: TelegramConfig | None = None
    workdir: Path | None = None
    workspace: WorkspaceConfig | None = None

    def __post_init__(self) -> None:
        # Backward compat: set workspace from first bot or workdir fallback.
        if self.workspace is None and self.bots:
            object.__setattr__(self, "workspace", self.bots[0].workspace)
        elif self.workspace is None and self.workdir is not None:
            workspace = WorkspaceConfig(root=self.workdir)
            object.__setattr__(self, "workspace", workspace)
        elif self.workspace is None:
            workspace = WorkspaceConfig(root=Path("~").expanduser())
            object.__setattr__(self, "workspace", workspace)

        if self.workdir is None and self.workspace is not None:
            object.__setattr__(self, "workdir", self.workspace.root.expanduser())
        elif self.workdir is not None:
            object.__setattr__(self, "workdir", self.workdir.expanduser())


def resolve_user(
    users: list[UserConfig],
    *,
    telegram_id: int | None = None,
    discord_id: int | None = None,
) -> UserConfig | None:
    """Look up a user by channel-specific ID.  Returns None if not found."""
    if telegram_id is not None:
        for user in users:
            if user.telegram_id == telegram_id:
                return user
    if discord_id is not None:
        for user in users:
            if user.discord_id == discord_id:
                return user
    return None


def _migrate_legacy_home() -> None:
    if "OTTO_HOME" in os.environ:
        return
    if OTTO_HOME.exists() or not _LEGACY_OTTO_HOME.exists():
        return
    OTTO_HOME.parent.mkdir(parents=True, exist_ok=True)
    shutil.move(str(_LEGACY_OTTO_HOME), str(OTTO_HOME))
    _LEGACY_OTTO_HOME.symlink_to(OTTO_HOME, target_is_directory=True)


def ensure_dirs() -> None:
    """Create the standard Otto directory tree."""
    _migrate_legacy_home()
    for directory in (OTTO_HOME, DATA_DIR, LOGS_DIR, SESSIONS_DIR):
        directory.mkdir(parents=True, exist_ok=True)


# ---------------------------------------------------------------------------
# Schema detection
# ---------------------------------------------------------------------------


def _is_new_schema(data: dict[str, Any]) -> bool:
    """Return True if the raw TOML data uses the new [[bots]] schema."""
    return "bots" in data and isinstance(data["bots"], list)


# ---------------------------------------------------------------------------
# Load
# ---------------------------------------------------------------------------


def load_config(path: Path | None = None) -> Config:
    """Load, validate, and resolve Otto configuration from TOML."""
    source_path = path or CONFIG_FILE
    raw_data = _read_toml(source_path)

    env_file_raw = raw_data.get("env_file")
    if env_file_raw is not None:
        _validate_type(env_file_raw, str, "env_file", source_path)
        _load_env_file(_resolve_env_file_path(_resolve_string(env_file_raw, "env_file")))

    resolved_data = _resolve_env_refs(copy.deepcopy(raw_data), "")

    if _is_new_schema(resolved_data):
        config = _build_config_new(resolved_data, source_path)
    else:
        config = _build_config_legacy(resolved_data, source_path)

    # Keep the original unresolved values so save_config can preserve ${VAR} references.
    object.__setattr__(config, "_raw_values", raw_data)
    return config


# ---------------------------------------------------------------------------
# Save
# ---------------------------------------------------------------------------


def save_config(config: Config, path: Path | None = None) -> None:
    """Write Otto configuration to TOML."""
    target_path = path or CONFIG_FILE
    target_path.parent.mkdir(parents=True, exist_ok=True)

    raw_values = getattr(config, "_raw_values", None)
    has_raw = isinstance(raw_values, dict)

    # Determine schema: if config has bots list, write new schema.
    if config.bots:
        _save_new_schema(config, target_path, raw_values if has_raw else None)
    else:
        _save_legacy_schema(config, target_path, raw_values if has_raw else None)


def _save_new_schema(config: Config, target_path: Path, raw_values: dict[str, Any] | None) -> None:
    doc = tomlkit.document()
    rv = raw_values or {}

    if config.env_file is not None:
        doc["env_file"] = rv.get("env_file", config.env_file)
    doc["log_level"] = rv.get("log_level", config.log_level)

    # [agent]
    agent_raw = rv.get("agent", {})
    if not isinstance(agent_raw, dict):
        agent_raw = {}
    agent_table = tomlkit.table()
    agent_table["model"] = agent_raw.get("model", config.agent.model)
    if config.agent.model_list:
        agent_table["model_list"] = config.agent.model_list
    agent_table["history_tool_results_keep"] = agent_raw.get(
        "history_tool_results_keep", config.agent.history_tool_results_keep
    )
    agent_table["compaction_enabled"] = agent_raw.get(
        "compaction_enabled", config.agent.compaction_enabled
    )
    agent_table["compaction_threshold"] = agent_raw.get(
        "compaction_threshold", config.agent.compaction_threshold
    )
    agent_table["compaction_keep_recent_messages"] = agent_raw.get(
        "compaction_keep_recent_messages", config.agent.compaction_keep_recent_messages
    )
    doc["agent"] = agent_table

    # [web]
    web_raw = rv.get("web", {})
    if not isinstance(web_raw, dict):
        web_raw = {}
    web_table = tomlkit.table()
    web_table["enabled"] = web_raw.get("enabled", config.web.enabled)
    web_host = web_raw.get("host", config.web.host)
    if web_host != "127.0.0.1":
        web_table["host"] = web_host
    web_hostname = web_raw.get("hostname", config.web.hostname)
    if web_hostname is not None:
        web_table["hostname"] = web_hostname
    web_table["port"] = web_raw.get("port", config.web.port)
    session_secret = web_raw.get("session_secret", config.web.session_secret)
    if session_secret is not None:
        web_table["session_secret"] = session_secret
    doc["web"] = web_table

    # [autonomy]
    autonomy_raw = rv.get("autonomy", {})
    if not isinstance(autonomy_raw, dict):
        autonomy_raw = {}
    autonomy_table = tomlkit.table()
    autonomy_table["enabled"] = autonomy_raw.get("enabled", config.autonomy.enabled)
    autonomy_table["mode"] = autonomy_raw.get("mode", config.autonomy.mode)
    autonomy_table["pulse_minutes"] = autonomy_raw.get(
        "pulse_minutes", config.autonomy.pulse_minutes
    )
    doc["autonomy"] = autonomy_table

    # [[users]]
    raw_users = rv.get("users", [])
    if not isinstance(raw_users, list):
        raw_users = []
    users_aot = tomlkit.aot()
    for idx, user in enumerate(config.users):
        user_table = tomlkit.table()
        raw_u = raw_users[idx] if idx < len(raw_users) and isinstance(raw_users[idx], dict) else {}
        user_table["name"] = raw_u.get("name", user.name)
        if user.telegram_id is not None:
            user_table["telegram_id"] = raw_u.get("telegram_id", user.telegram_id)
        if user.discord_id is not None:
            user_table["discord_id"] = raw_u.get("discord_id", user.discord_id)
        users_aot.append(user_table)
    doc["users"] = users_aot

    # [[bots]]
    raw_bots = rv.get("bots", [])
    if not isinstance(raw_bots, list):
        raw_bots = []
    bots_aot = tomlkit.aot()
    for idx, bot in enumerate(config.bots):
        raw_b = raw_bots[idx] if idx < len(raw_bots) and isinstance(raw_bots[idx], dict) else {}
        bot_table = tomlkit.table()
        bot_table["name"] = raw_b.get("name", bot.name)
        if bot.model is not None:
            bot_table["model"] = raw_b.get("model", bot.model)
        raw_thinking = raw_b.get("thinking_default")
        if raw_thinking is not None or bot.thinking_default != "off":
            if isinstance(raw_thinking, str):
                normalized_raw = _normalize_thinking_default(raw_thinking)
            else:
                normalized_raw = None
            if normalized_raw == bot.thinking_default and raw_thinking is not None:
                bot_table["thinking_default"] = raw_thinking
            else:
                bot_table["thinking_default"] = bot.thinking_default

        # [bots.auth]
        raw_auth = raw_b.get("auth", {})
        if not isinstance(raw_auth, dict):
            raw_auth = {}
        auth_table = tomlkit.table()
        auth_table["owner"] = raw_auth.get("owner", bot.auth.owner)
        auth_table["allowed_users"] = raw_auth.get("allowed_users", list(bot.auth.allowed_users))
        auth_table["bootstrap"] = raw_auth.get("bootstrap", bot.auth.bootstrap)
        bot_table["auth"] = auth_table

        # [bots.workspace]
        raw_ws = raw_b.get("workspace", {})
        if not isinstance(raw_ws, dict):
            raw_ws = {}
        ws_table = tomlkit.table()
        ws_table["root"] = raw_ws.get("root", str(bot.workspace.root))
        ws_table["mode"] = raw_ws.get("mode", bot.workspace.mode)
        ws_table["sandbox"] = raw_ws.get("sandbox", bot.workspace.sandbox)
        ws_table["sandbox_network"] = raw_ws.get("sandbox_network", bot.workspace.sandbox_network)
        bot_table["workspace"] = ws_table

        # [bots.skills]
        raw_sk = raw_b.get("skills", {})
        if not isinstance(raw_sk, dict):
            raw_sk = {}
        skills_table = tomlkit.table()
        skills_table["include_shared"] = raw_sk.get("include_shared", bot.skills.include_shared)
        skills_table["allow_global_skills_in_sandbox"] = raw_sk.get(
            "allow_global_skills_in_sandbox", bot.skills.allow_global_skills_in_sandbox
        )
        bot_table["skills"] = skills_table

        # [[bots.channels]]
        raw_channels = raw_b.get("channels", [])
        if not isinstance(raw_channels, list):
            raw_channels = []
        if bot.channels:
            channels_aot = tomlkit.aot()
            for ch_idx, channel in enumerate(bot.channels):
                raw_ch = (
                    raw_channels[ch_idx]
                    if ch_idx < len(raw_channels) and isinstance(raw_channels[ch_idx], dict)
                    else {}
                )
                ch_table = tomlkit.table()
                ch_table["type"] = raw_ch.get("type", channel.type)
                if channel.token is not None:
                    ch_table["token"] = raw_ch.get("token", channel.token)
                if channel.application_id is not None:
                    ch_table["application_id"] = raw_ch.get(
                        "application_id", channel.application_id
                    )
                if channel.guild_id is not None:
                    ch_table["guild_id"] = raw_ch.get("guild_id", channel.guild_id)
                if channel.type == "web" or channel.port is not None:
                    if channel.port is not None:
                        ch_table["port"] = raw_ch.get("port", channel.port)
                if not channel.enabled:
                    ch_table["enabled"] = False
                channels_aot.append(ch_table)
            bot_table["channels"] = channels_aot

        bots_aot.append(bot_table)
    doc["bots"] = bots_aot

    _atomic_write_text(target_path, tomlkit.dumps(doc))


def _save_legacy_schema(
    config: Config, target_path: Path, raw_values: dict[str, Any] | None
) -> None:
    """Write legacy telegram-centric schema."""
    rv = raw_values or {}
    preserve_legacy = raw_values is not None

    if not preserve_legacy:
        values = _serialize_config_legacy(config)
    else:
        values = copy.deepcopy(rv)

    doc = tomlkit.document()
    if "env_file" in values:
        doc["env_file"] = values["env_file"]
    elif config.env_file is not None:
        doc["env_file"] = config.env_file
    doc["log_level"] = values.get("log_level", config.log_level)
    doc["workdir"] = values.get("workdir", str(config.workdir))

    workspace_values = values.get("workspace", {})
    if not isinstance(workspace_values, dict):
        workspace_values = {}
    workspace_table = tomlkit.table()
    workspace_table["root"] = workspace_values.get(
        "root", values.get("workdir", str(config.workspace.root))
    )
    workspace_table["mode"] = workspace_values.get("mode", config.workspace.mode)
    workspace_table["sandbox"] = workspace_values.get("sandbox", config.workspace.sandbox)
    workspace_table["sandbox_network"] = workspace_values.get(
        "sandbox_network", config.workspace.sandbox_network
    )
    doc["workspace"] = workspace_table

    telegram_values = values.get("telegram", {})
    if not isinstance(telegram_values, dict):
        telegram_values = {}
    doc["telegram"] = _build_telegram_table(
        config.telegram, telegram_values, preserve_legacy=preserve_legacy
    )

    agent_values = values.get("agent", {})
    agent_table = tomlkit.table()
    agent_table["model"] = agent_values.get("model", config.agent.model)
    model_list = config.agent.model_list
    if model_list:
        agent_table["model_list"] = model_list
    agent_table["history_tool_results_keep"] = agent_values.get(
        "history_tool_results_keep", config.agent.history_tool_results_keep
    )
    agent_table["compaction_enabled"] = agent_values.get(
        "compaction_enabled", config.agent.compaction_enabled
    )
    agent_table["compaction_threshold"] = agent_values.get(
        "compaction_threshold", config.agent.compaction_threshold
    )
    agent_table["compaction_keep_recent_messages"] = agent_values.get(
        "compaction_keep_recent_messages", config.agent.compaction_keep_recent_messages
    )
    doc["agent"] = agent_table

    web_values = values.get("web", {})
    web_table = tomlkit.table()
    web_table["enabled"] = web_values.get("enabled", config.web.enabled)
    legacy_web_host = web_values.get("host", config.web.host)
    if legacy_web_host != "127.0.0.1":
        web_table["host"] = legacy_web_host
    legacy_web_hostname = web_values.get("hostname", config.web.hostname)
    if legacy_web_hostname is not None:
        web_table["hostname"] = legacy_web_hostname
    web_table["port"] = web_values.get("port", config.web.port)
    legacy_session_secret = web_values.get("session_secret", config.web.session_secret)
    if legacy_session_secret is not None:
        web_table["session_secret"] = legacy_session_secret
    doc["web"] = web_table

    autonomy_values = values.get("autonomy", {})
    if not isinstance(autonomy_values, dict):
        autonomy_values = {}
    autonomy_table = tomlkit.table()
    autonomy_table["enabled"] = autonomy_values.get("enabled", config.autonomy.enabled)
    autonomy_table["mode"] = autonomy_values.get("mode", config.autonomy.mode)
    autonomy_table["pulse_minutes"] = autonomy_values.get(
        "pulse_minutes", config.autonomy.pulse_minutes
    )
    doc["autonomy"] = autonomy_table

    _atomic_write_text(target_path, tomlkit.dumps(doc))


def _build_telegram_table(
    config: TelegramConfig, raw_values: dict[str, Any], *, preserve_legacy: bool
) -> Any:
    table = tomlkit.table()
    has_raw_token = "token" in raw_values
    has_raw_bots = "bots" in raw_values

    # Keep writing legacy token unless this file was explicitly using only telegram.bots.
    if has_raw_token or not has_raw_bots:
        table["token"] = raw_values.get("token", config.token)
    table["owner_id"] = raw_values.get("owner_id", config.owner_id)
    table["allowed_users"] = raw_values.get("allowed_users", list(config.allowed_users))
    table["bootstrap"] = raw_values.get("bootstrap", config.bootstrap)

    if has_raw_bots:
        raw_bots = raw_values.get("bots")
        bots_values = _normalize_raw_bots(raw_bots)
    elif preserve_legacy:
        bots_values = []
    else:
        bots_values = _serialize_telegram_bots(config.bots)

    if bots_values:
        bots_aot = tomlkit.aot()
        for bot_idx, bot in enumerate(bots_values):
            bot_table = tomlkit.table()
            bot_table["alias"] = bot.get("alias", "")
            bot_table["token"] = bot.get("token", "")
            # Merge persisted model from the config dataclass if not in raw values
            bot_model = bot.get("model")
            if bot_model is None and bot_idx < len(config.bots):
                bot_model = config.bots[bot_idx].model
            if bot_model is not None:
                bot_table["model"] = bot_model
            bots_aot.append(bot_table)
        table["bots"] = bots_aot

    return table


def _serialize_config_legacy(config: Config) -> dict[str, Any]:
    data: dict[str, Any] = {
        "log_level": config.log_level,
        "workdir": str(config.workspace.root),
        "workspace": {
            "root": str(config.workspace.root),
            "mode": config.workspace.mode,
            "sandbox": config.workspace.sandbox,
            "sandbox_network": config.workspace.sandbox_network,
        },
        "telegram": _serialize_telegram_config(config.telegram),
        "agent": {
            "model": config.agent.model,
            "model_list": config.agent.model_list,
            "history_tool_results_keep": config.agent.history_tool_results_keep,
            "compaction_enabled": config.agent.compaction_enabled,
            "compaction_threshold": config.agent.compaction_threshold,
            "compaction_keep_recent_messages": config.agent.compaction_keep_recent_messages,
        },
        "web": {
            "enabled": config.web.enabled,
            "host": config.web.host,
            "hostname": config.web.hostname,
            "port": config.web.port,
        },
        "autonomy": {
            "enabled": config.autonomy.enabled,
            "mode": config.autonomy.mode,
            "pulse_minutes": config.autonomy.pulse_minutes,
        },
    }
    if config.env_file is not None:
        data["env_file"] = config.env_file
    return data


def _serialize_telegram_config(config: TelegramConfig) -> dict[str, Any]:
    data: dict[str, Any] = {
        "token": config.token,
        "owner_id": config.owner_id,
        "allowed_users": list(config.allowed_users),
        "bootstrap": config.bootstrap,
    }
    bots = _serialize_telegram_bots(config.bots)
    if bots:
        data["bots"] = bots
    return data


def _serialize_telegram_bots(bots: list[TelegramBotConfig]) -> list[dict[str, Any]]:
    result: list[dict[str, Any]] = []
    for bot in bots:
        entry: dict[str, Any] = {"alias": bot.alias, "token": bot.token}
        if bot.model is not None:
            entry["model"] = bot.model
        result.append(entry)
    return result


def _normalize_raw_bots(raw_bots: Any) -> list[dict[str, Any]]:
    if isinstance(raw_bots, list):
        return [bot for bot in raw_bots if isinstance(bot, dict)]

    if isinstance(raw_bots, dict):
        converted: list[dict[str, Any]] = []
        for alias, bot in raw_bots.items():
            if not isinstance(bot, dict):
                continue
            entry = copy.deepcopy(bot)
            entry.setdefault("alias", alias)
            converted.append(entry)
        return converted

    return []


# ---------------------------------------------------------------------------
# TOML reading / env helpers
# ---------------------------------------------------------------------------


def _read_toml(path: Path) -> dict[str, Any]:
    try:
        with path.open("rb") as file:
            data = tomllib.load(file)
    except FileNotFoundError as exc:
        raise ConfigError(
            f"Config file not found: {path}. Create it with required fields before starting Otto."
        ) from exc
    except tomllib.TOMLDecodeError as exc:
        raise ConfigError(f"Invalid TOML in {path}: {exc}") from exc

    if not isinstance(data, dict):
        raise ConfigError(
            f"Invalid config structure in {path}. Expected a TOML table at top level."
        )
    return data


def _load_env_file(path: Path) -> None:
    if not path.exists():
        return

    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line.removeprefix("export ").strip()
        if "=" not in line:
            continue

        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip()
        if not key:
            continue

        if len(value) >= 2 and value[0] in {"'", '"'} and value[-1] == value[0]:
            value = value[1:-1]
        os.environ.setdefault(key, value)


def _resolve_env_file_path(value: str) -> Path:
    candidate = Path(value).expanduser()
    if not candidate.is_absolute():
        candidate = OTTO_HOME / candidate
    return candidate


def _resolve_env_refs(value: Any, field_path: str) -> Any:
    if isinstance(value, dict):
        resolved: dict[str, Any] = {}
        for key, nested_value in value.items():
            nested_path = f"{field_path}.{key}" if field_path else str(key)
            resolved[key] = _resolve_env_refs(nested_value, nested_path)
        return resolved

    if isinstance(value, list):
        return [
            _resolve_env_refs(item, f"{field_path}[{index}]") for index, item in enumerate(value)
        ]

    if isinstance(value, str):
        return _resolve_string(value, field_path)

    return value


def _resolve_string(value: str, field_path: str) -> str:
    def replace(match: re.Match[str]) -> str:
        variable_name = match.group(1)
        resolved = os.environ.get(variable_name)
        if resolved is None:
            raise ConfigError(
                f"Environment variable '{variable_name}' referenced by '{field_path}' is not set. "
                f"Set it in your environment or env_file."
            )
        return resolved

    return _ENV_REF_PATTERN.sub(replace, value)


# ---------------------------------------------------------------------------
# Build config — new [[bots]] schema
# ---------------------------------------------------------------------------


def _build_config_new(data: dict[str, Any], source_path: Path) -> Config:
    agent_table = _require_mapping(_require_field(data, "agent", source_path), "agent", source_path)
    web_table_raw = data.get("web", {})
    web_table = _require_mapping(web_table_raw, "web", source_path)
    autonomy_table_raw = data.get("autonomy", {})
    autonomy_table = _require_mapping(autonomy_table_raw, "autonomy", source_path)

    # [agent]
    model = _require_field(agent_table, "model", source_path, field_path="agent.model")
    _validate_type(model, str, "agent.model", source_path)
    history_keep_raw = agent_table.get("history_tool_results_keep", 4)
    _validate_type(history_keep_raw, int, "agent.history_tool_results_keep", source_path)

    compaction_enabled_raw = agent_table.get("compaction_enabled", True)
    _validate_type(compaction_enabled_raw, bool, "agent.compaction_enabled", source_path)

    compaction_threshold_raw = agent_table.get("compaction_threshold", 0.8)
    if not isinstance(compaction_threshold_raw, (int, float)):
        raise ConfigError(_type_error("agent.compaction_threshold", "float", source_path))
    compaction_threshold = float(compaction_threshold_raw)
    if not (0 < compaction_threshold < 1):
        raise ConfigError(
            f"Invalid value for 'agent.compaction_threshold' in {source_path}. "
            "Expected a float between 0 and 1."
        )

    compaction_keep_recent_raw = agent_table.get("compaction_keep_recent_messages", 8)
    _validate_type(
        compaction_keep_recent_raw,
        int,
        "agent.compaction_keep_recent_messages",
        source_path,
    )

    delegation_timeout_raw = agent_table.get("delegation_timeout", 1800)
    _validate_type(delegation_timeout_raw, int, "agent.delegation_timeout", source_path)

    model_list_raw = agent_table.get("model_list", [])
    if not isinstance(model_list_raw, list):
        raise ConfigError(_type_error("agent.model_list", "list[str]", source_path))
    for idx, item in enumerate(model_list_raw):
        _validate_type(item, str, f"agent.model_list[{idx}]", source_path)

    # [web]
    web_enabled = web_table.get("enabled", True)
    _validate_type(web_enabled, bool, "web.enabled", source_path)
    web_port = web_table.get("port", 7070)
    _validate_type(web_port, int, "web.port", source_path)
    web_host = web_table.get("host", "127.0.0.1")
    _validate_type(web_host, str, "web.host", source_path)
    if web_host not in ("127.0.0.1", "0.0.0.0"):
        raise ConfigError(f"web.host must be '127.0.0.1' or '0.0.0.0' in {source_path}")
    web_hostname = web_table.get("hostname")
    if web_hostname is not None:
        _validate_type(web_hostname, str, "web.hostname", source_path)
    web_session_secret = web_table.get("session_secret")
    if web_session_secret is not None:
        _validate_type(web_session_secret, str, "web.session_secret", source_path)
    autonomy = _parse_autonomy_config(autonomy_table, source_path)

    # log_level
    log_level = _require_field(data, "log_level", source_path)
    _validate_type(log_level, str, "log_level", source_path)

    # env_file
    env_file = data.get("env_file")
    if env_file is not None:
        _validate_type(env_file, str, "env_file", source_path)

    # [[users]]
    users_raw = data.get("users", [])
    if not isinstance(users_raw, list):
        raise ConfigError(_type_error("users", "list[table]", source_path))
    users = _parse_users(users_raw, source_path)

    # [[bots]]
    bots_raw = data.get("bots", [])
    if not isinstance(bots_raw, list):
        raise ConfigError(_type_error("bots", "list[table]", source_path))
    if not bots_raw:
        raise ConfigError(
            f"Missing required config field 'bots' in {source_path}. "
            f"At least one [[bots]] entry is required."
        )
    bots = _parse_bots(bots_raw, source_path, users)

    # [acp_agents] — optional user-defined or overridden ACP agent commands
    acp_agents_raw = data.get("acp_agents")
    if acp_agents_raw is not None:
        if not isinstance(acp_agents_raw, dict):
            raise ConfigError(_type_error("acp_agents", "table", source_path))
        for key, val in acp_agents_raw.items():
            if not isinstance(val, dict):
                raise ConfigError(_type_error(f"acp_agents.{key}", "table", source_path))
            if "command" not in val:
                raise ConfigError(
                    f"Missing required field 'command' in acp_agents.{key} in {source_path}"
                )
            cmd = val["command"]
            if not isinstance(cmd, list) or not all(isinstance(c, str) for c in cmd):
                raise ConfigError(
                    _type_error(f"acp_agents.{key}.command", "list[str]", source_path)
                )

    return Config(
        agent=AgentConfig(
            model=model,
            model_list=list(model_list_raw),
            history_tool_results_keep=int(history_keep_raw),
            compaction_enabled=bool(compaction_enabled_raw),
            compaction_threshold=compaction_threshold,
            compaction_keep_recent_messages=int(compaction_keep_recent_raw),
            delegation_timeout=int(delegation_timeout_raw),
        ),
        log_level=log_level,
        env_file=env_file,
        users=users,
        bots=bots,
        web=WebConfig(
            enabled=web_enabled,
            host=web_host,
            hostname=web_hostname,
            port=web_port,
            session_secret=web_session_secret,
        ),
        autonomy=autonomy,
        acp_agents=acp_agents_raw,
    )


def _parse_users(users_raw: list[Any], source_path: Path) -> list[UserConfig]:
    users: list[UserConfig] = []
    seen_names: set[str] = set()
    for idx, entry in enumerate(users_raw):
        path = f"users[{idx}]"
        table = _require_mapping(entry, path, source_path)
        name = _require_field(table, "name", source_path, field_path=f"{path}.name")
        _validate_type(name, str, f"{path}.name", source_path)
        if name in seen_names:
            raise ConfigError(f"Duplicate user name '{name}' in {source_path}.")
        seen_names.add(name)

        telegram_id = table.get("telegram_id")
        if telegram_id is not None and not isinstance(telegram_id, int):
            raise ConfigError(_type_error(f"{path}.telegram_id", "int | None", source_path))

        discord_id = table.get("discord_id")
        if discord_id is not None and not isinstance(discord_id, int):
            raise ConfigError(_type_error(f"{path}.discord_id", "int | None", source_path))

        users.append(UserConfig(name=name, telegram_id=telegram_id, discord_id=discord_id))
    return users


def _normalize_thinking_default(value: str) -> str | None:
    normalized = value.strip().lower()
    aliases = {
        "none": "off",
        "disabled": "off",
        "disable": "off",
        "on": "medium",
        "enabled": "medium",
        "med": "medium",
        "x-high": "xhigh",
        "x_high": "xhigh",
    }
    normalized = aliases.get(normalized, normalized)
    if normalized in {"off", "low", "medium", "high", "xhigh"}:
        return normalized
    return None


def _parse_bots(bots_raw: list[Any], source_path: Path, users: list[UserConfig]) -> list[BotConfig]:
    bots: list[BotConfig] = []
    seen_names: set[str] = set()
    user_names = {u.name for u in users}

    for idx, entry in enumerate(bots_raw):
        path = f"bots[{idx}]"
        table = _require_mapping(entry, path, source_path)

        name = _require_field(table, "name", source_path, field_path=f"{path}.name")
        _validate_type(name, str, f"{path}.name", source_path)
        if name in seen_names:
            raise ConfigError(f"Duplicate bot name '{name}' in {source_path}.")
        seen_names.add(name)

        bot_model = table.get("model")
        if bot_model is not None:
            _validate_type(bot_model, str, f"{path}.model", source_path)

        thinking_default_raw = table.get("thinking_default", "off")
        _validate_type(thinking_default_raw, str, f"{path}.thinking_default", source_path)
        thinking_default = _normalize_thinking_default(thinking_default_raw)
        if thinking_default is None:
            raise ConfigError(
                f"Invalid value for '{path}.thinking_default' in {source_path}. "
                "Expected one of: off, low, medium, high, xhigh."
            )

        # [bots.auth]
        auth_raw = _require_field(table, "auth", source_path, field_path=f"{path}.auth")
        auth_table = _require_mapping(auth_raw, f"{path}.auth", source_path)
        owner = _require_field(auth_table, "owner", source_path, field_path=f"{path}.auth.owner")
        _validate_type(owner, str, f"{path}.auth.owner", source_path)
        if user_names and owner not in user_names:
            raise ConfigError(
                f"Bot '{name}' auth.owner '{owner}' does not match any defined user in {source_path}."
            )
        allowed_raw = _require_field(
            auth_table, "allowed_users", source_path, field_path=f"{path}.auth.allowed_users"
        )
        if not isinstance(allowed_raw, list):
            raise ConfigError(_type_error(f"{path}.auth.allowed_users", "list[str]", source_path))
        for au_idx, au in enumerate(allowed_raw):
            _validate_type(au, str, f"{path}.auth.allowed_users[{au_idx}]", source_path)
        bootstrap = auth_table.get("bootstrap", "disabled")
        _validate_type(bootstrap, str, f"{path}.auth.bootstrap", source_path)
        if bootstrap not in {"first_user", "disabled"}:
            raise ConfigError(
                f"Invalid value for '{path}.auth.bootstrap' in {source_path}. "
                "Expected 'first_user' or 'disabled'."
            )
        auth = BotAuthConfig(owner=owner, allowed_users=list(allowed_raw), bootstrap=bootstrap)

        # [bots.workspace]
        ws_raw = _require_field(table, "workspace", source_path, field_path=f"{path}.workspace")
        ws_table = _require_mapping(ws_raw, f"{path}.workspace", source_path)
        ws_root_raw = _require_field(
            ws_table, "root", source_path, field_path=f"{path}.workspace.root"
        )
        _validate_type(ws_root_raw, str, f"{path}.workspace.root", source_path)
        ws_mode = ws_table.get("mode", "default")
        _validate_type(ws_mode, str, f"{path}.workspace.mode", source_path)
        if ws_mode not in {"default", "strict"}:
            raise ConfigError(
                f"Invalid value for '{path}.workspace.mode' in {source_path}. "
                "Expected 'strict' or 'default'."
            )
        ws_sandbox = ws_table.get("sandbox", "none")
        _validate_type(ws_sandbox, str, f"{path}.workspace.sandbox", source_path)
        if ws_sandbox not in {"none", "bubblewrap"}:
            raise ConfigError(
                f"Invalid value for '{path}.workspace.sandbox' in {source_path}. "
                "Expected 'none' or 'bubblewrap'."
            )
        ws_sandbox_network = ws_table.get("sandbox_network", True)
        _validate_type(
            ws_sandbox_network,
            bool,
            f"{path}.workspace.sandbox_network",
            source_path,
        )
        workspace = WorkspaceConfig(
            root=Path(ws_root_raw).expanduser(),
            mode=ws_mode,
            sandbox=ws_sandbox,
            sandbox_network=ws_sandbox_network,
        )

        # [bots.skills]
        skills_raw = table.get("skills", {})
        skills_table = _require_mapping(skills_raw, f"{path}.skills", source_path)
        include_shared = skills_table.get("include_shared", True)
        _validate_type(include_shared, bool, f"{path}.skills.include_shared", source_path)
        allow_global_in_sandbox = skills_table.get("allow_global_skills_in_sandbox", False)
        _validate_type(
            allow_global_in_sandbox,
            bool,
            f"{path}.skills.allow_global_skills_in_sandbox",
            source_path,
        )
        skills = BotSkillsConfig(
            include_shared=include_shared,
            allow_global_skills_in_sandbox=allow_global_in_sandbox,
        )

        # [[bots.channels]]
        channels_raw = table.get("channels", [])
        if not isinstance(channels_raw, list):
            raise ConfigError(_type_error(f"{path}.channels", "list[table]", source_path))
        channels = _parse_channels(channels_raw, source_path, path)

        bots.append(
            BotConfig(
                name=name,
                model=bot_model,
                auth=auth,
                workspace=workspace,
                skills=skills,
                channels=channels,
                thinking_default=thinking_default,
            )
        )

    return bots


def _parse_channels(
    channels_raw: list[Any], source_path: Path, bot_path: str
) -> list[ChannelConfig]:
    channels: list[ChannelConfig] = []
    for idx, entry in enumerate(channels_raw):
        path = f"{bot_path}.channels[{idx}]"
        table = _require_mapping(entry, path, source_path)

        ch_type = _require_field(table, "type", source_path, field_path=f"{path}.type")
        _validate_type(ch_type, str, f"{path}.type", source_path)
        normalized_type = ch_type.strip().lower()

        token = table.get("token")
        if token is not None:
            _validate_type(token, str, f"{path}.token", source_path)

        application_id = table.get("application_id")
        if application_id is not None:
            _validate_type(application_id, str, f"{path}.application_id", source_path)
            if not application_id.strip():
                raise ConfigError(
                    f"Invalid value for '{path}.application_id' in {source_path}. "
                    "Application ID cannot be empty."
                )

        guild_id = table.get("guild_id")
        if guild_id is not None:
            _validate_type(guild_id, str, f"{path}.guild_id", source_path)
            if not guild_id.strip():
                raise ConfigError(
                    f"Invalid value for '{path}.guild_id' in {source_path}. Guild ID cannot be empty."
                )

        enabled = table.get("enabled", True)
        _validate_type(enabled, bool, f"{path}.enabled", source_path)

        port = table.get("port")
        if port is not None:
            _validate_type(port, int, f"{path}.port", source_path)

        if normalized_type == "discord":
            if token is None:
                raise ConfigError(
                    f"Missing required config field '{path}.token' in {source_path}. "
                    "Discord channels require a bot token."
                )
            if application_id is None:
                raise ConfigError(
                    f"Missing required config field '{path}.application_id' in {source_path}. "
                    "Discord channels require an application ID."
                )
            if port is not None:
                raise ConfigError(
                    f"Invalid config field '{path}.port' in {source_path}. "
                    "Discord channels do not use a port field."
                )

        channels.append(
            ChannelConfig(
                type=ch_type,
                token=token,
                application_id=application_id,
                guild_id=guild_id,
                enabled=enabled,
                port=port,
            )
        )
    return channels


def _parse_autonomy_config(autonomy_table: dict[str, Any], source_path: Path) -> AutonomyConfig:
    defaults = AutonomyConfig()

    enabled = autonomy_table.get("enabled", defaults.enabled)
    _validate_type(enabled, bool, "autonomy.enabled", source_path)

    mode = autonomy_table.get("mode", defaults.mode)
    _validate_type(mode, str, "autonomy.mode", source_path)
    if mode not in {"passive", "notify"}:
        raise ConfigError(
            f"Invalid value for 'autonomy.mode' in {source_path}. Expected 'passive' or 'notify'."
        )

    pulse_minutes = autonomy_table.get("pulse_minutes", defaults.pulse_minutes)
    _validate_type(pulse_minutes, int, "autonomy.pulse_minutes", source_path)
    if pulse_minutes < 1:
        raise ConfigError(
            f"Invalid value for 'autonomy.pulse_minutes' in {source_path}. "
            "Expected an integer greater than or equal to 1."
        )

    return AutonomyConfig(
        enabled=enabled,
        mode=mode,
        pulse_minutes=int(pulse_minutes),
    )


# ---------------------------------------------------------------------------
# Build config — legacy [telegram] schema
# ---------------------------------------------------------------------------


def _build_config_legacy(data: dict[str, Any], source_path: Path) -> Config:
    telegram_table = _require_mapping(
        _require_field(data, "telegram", source_path), "telegram", source_path
    )
    agent_table = _require_mapping(_require_field(data, "agent", source_path), "agent", source_path)
    workspace_table_raw = data.get("workspace", {})
    workspace_table = _require_mapping(workspace_table_raw, "workspace", source_path)
    web_table_raw = data.get("web", {})
    web_table = _require_mapping(web_table_raw, "web", source_path)
    autonomy_table_raw = data.get("autonomy", {})
    autonomy_table = _require_mapping(autonomy_table_raw, "autonomy", source_path)

    token_raw = telegram_table.get("token", _MISSING)
    bots_raw = telegram_table.get("bots", _MISSING)

    if bots_raw is _MISSING:
        token = _require_field(telegram_table, "token", source_path, field_path="telegram.token")
        _validate_type(token, str, "telegram.token", source_path)
        tg_bots = [TelegramBotConfig(alias="default", token=token)]
    else:
        tg_bots = _parse_telegram_bots(bots_raw, source_path)
        if token_raw is _MISSING:
            token = tg_bots[0].token
        else:
            _validate_type(token_raw, str, "telegram.token", source_path)
            token = token_raw

    owner_id = _require_field(
        telegram_table, "owner_id", source_path, field_path="telegram.owner_id"
    )
    if owner_id is not None and not isinstance(owner_id, int):
        raise ConfigError(_type_error("telegram.owner_id", "int | None", source_path))

    allowed_users = _require_field(
        telegram_table, "allowed_users", source_path, field_path="telegram.allowed_users"
    )
    if not isinstance(allowed_users, list):
        raise ConfigError(_type_error("telegram.allowed_users", "list[int]", source_path))
    if not all(isinstance(item, int) for item in allowed_users):
        raise ConfigError(
            f"Invalid value for 'telegram.allowed_users' in {source_path}. "
            "Expected every entry to be an integer user id."
        )

    bootstrap = _require_field(
        telegram_table, "bootstrap", source_path, field_path="telegram.bootstrap"
    )
    _validate_type(bootstrap, str, "telegram.bootstrap", source_path)
    if bootstrap not in {"first_user", "disabled"}:
        raise ConfigError(
            f"Invalid value for 'telegram.bootstrap' in {source_path}. "
            "Expected 'first_user' or 'disabled'."
        )

    model = _require_field(agent_table, "model", source_path, field_path="agent.model")
    _validate_type(model, str, "agent.model", source_path)

    history_tool_results_keep_raw = agent_table.get("history_tool_results_keep", 4)
    _validate_type(
        history_tool_results_keep_raw, int, "agent.history_tool_results_keep", source_path
    )
    history_tool_results_keep = int(history_tool_results_keep_raw)

    compaction_enabled_raw = agent_table.get("compaction_enabled", True)
    _validate_type(compaction_enabled_raw, bool, "agent.compaction_enabled", source_path)

    compaction_threshold_raw = agent_table.get("compaction_threshold", 0.8)
    if not isinstance(compaction_threshold_raw, (int, float)):
        raise ConfigError(_type_error("agent.compaction_threshold", "float", source_path))
    compaction_threshold = float(compaction_threshold_raw)
    if not (0 < compaction_threshold < 1):
        raise ConfigError(
            f"Invalid value for 'agent.compaction_threshold' in {source_path}. "
            "Expected a float between 0 and 1."
        )

    compaction_keep_recent_raw = agent_table.get("compaction_keep_recent_messages", 8)
    _validate_type(
        compaction_keep_recent_raw,
        int,
        "agent.compaction_keep_recent_messages",
        source_path,
    )
    compaction_keep_recent = int(compaction_keep_recent_raw)

    delegation_timeout_raw_legacy = agent_table.get("delegation_timeout", 1800)
    _validate_type(delegation_timeout_raw_legacy, int, "agent.delegation_timeout", source_path)

    model_list_raw = agent_table.get("model_list", [])
    if not isinstance(model_list_raw, list):
        raise ConfigError(_type_error("agent.model_list", "list[str]", source_path))
    for idx, item in enumerate(model_list_raw):
        _validate_type(item, str, f"agent.model_list[{idx}]", source_path)

    web_enabled = web_table.get("enabled", True)
    _validate_type(web_enabled, bool, "web.enabled", source_path)

    web_port = web_table.get("port", 7070)
    _validate_type(web_port, int, "web.port", source_path)

    web_host = web_table.get("host", "127.0.0.1")
    _validate_type(web_host, str, "web.host", source_path)
    if web_host not in ("127.0.0.1", "0.0.0.0"):
        raise ConfigError(f"web.host must be '127.0.0.1' or '0.0.0.0' in {source_path}")
    web_hostname = web_table.get("hostname")
    if web_hostname is not None:
        _validate_type(web_hostname, str, "web.hostname", source_path)

    web_session_secret = web_table.get("session_secret")
    if web_session_secret is not None:
        _validate_type(web_session_secret, str, "web.session_secret", source_path)

    autonomy = _parse_autonomy_config(autonomy_table, source_path)

    log_level = _require_field(data, "log_level", source_path)
    _validate_type(log_level, str, "log_level", source_path)

    workdir_raw = data.get("workdir", _MISSING)
    if workdir_raw is not _MISSING:
        _validate_type(workdir_raw, str, "workdir", source_path)

    workspace_root_raw = workspace_table.get("root", workdir_raw)
    if workspace_root_raw is _MISSING:
        workspace_root_raw = "~"
    _validate_type(workspace_root_raw, str, "workspace.root", source_path)

    workspace_mode = workspace_table.get("mode", "default")
    _validate_type(workspace_mode, str, "workspace.mode", source_path)
    if workspace_mode not in {"default", "strict"}:
        raise ConfigError(
            f"Invalid value for 'workspace.mode' in {source_path}. Expected 'strict' or 'default'."
        )

    workspace_sandbox = workspace_table.get("sandbox", "none")
    _validate_type(workspace_sandbox, str, "workspace.sandbox", source_path)
    if workspace_sandbox not in {"none", "bubblewrap"}:
        raise ConfigError(
            f"Invalid value for 'workspace.sandbox' in {source_path}. "
            "Expected 'none' or 'bubblewrap'."
        )

    workspace_sandbox_network = workspace_table.get("sandbox_network", True)
    _validate_type(workspace_sandbox_network, bool, "workspace.sandbox_network", source_path)

    workspace_root = Path(workspace_root_raw).expanduser()
    workdir_value = str(workspace_root)

    env_file = data.get("env_file")
    if env_file is not None:
        _validate_type(env_file, str, "env_file", source_path)

    return Config(
        telegram=TelegramConfig(
            token=token,
            owner_id=owner_id,
            allowed_users=allowed_users,
            bootstrap=bootstrap,
            bots=tg_bots,
        ),
        agent=AgentConfig(
            model=model,
            model_list=list(model_list_raw),
            history_tool_results_keep=history_tool_results_keep,
            compaction_enabled=bool(compaction_enabled_raw),
            compaction_threshold=compaction_threshold,
            compaction_keep_recent_messages=compaction_keep_recent,
            delegation_timeout=int(delegation_timeout_raw_legacy),
        ),
        log_level=log_level,
        workdir=Path(workdir_value).expanduser(),
        env_file=env_file,
        workspace=WorkspaceConfig(
            root=workspace_root,
            mode=workspace_mode,
            sandbox=workspace_sandbox,
            sandbox_network=workspace_sandbox_network,
        ),
        web=WebConfig(
            enabled=web_enabled,
            host=web_host,
            hostname=web_hostname,
            port=web_port,
            session_secret=web_session_secret,
        ),
        autonomy=autonomy,
    )


def _parse_telegram_bots(value: Any, source_path: Path) -> list[TelegramBotConfig]:
    bots: list[TelegramBotConfig] = []
    seen_aliases: set[str] = set()
    seen_tokens: dict[str, str] = {}

    def add_bot(
        alias: Any, token: Any, alias_field: str, token_field: str, bot_model: str | None = None
    ) -> None:
        _validate_type(alias, str, alias_field, source_path)
        if not alias.strip():
            raise ConfigError(
                f"Invalid value for '{alias_field}' in {source_path}. Alias cannot be empty."
            )
        if alias in seen_aliases:
            raise ConfigError(
                f"Duplicate telegram bot alias '{alias}' in {source_path}. "
                "Each entry in 'telegram.bots' must use a unique alias."
            )
        seen_aliases.add(alias)

        _validate_type(token, str, token_field, source_path)
        if not token.strip():
            raise ConfigError(
                f"Invalid value for '{token_field}' in {source_path}. Token cannot be empty."
            )

        first_alias = seen_tokens.get(token)
        if first_alias is not None:
            raise ConfigError(
                f"Duplicate telegram bot token in {source_path}. "
                f"Aliases '{first_alias}' and '{alias}' must use different tokens."
            )
        seen_tokens[token] = alias
        bots.append(TelegramBotConfig(alias=alias, token=token, model=bot_model))

    if isinstance(value, list):
        if not value:
            raise ConfigError(
                f"Invalid value for 'telegram.bots' in {source_path}. "
                "Expected at least one bot entry."
            )

        for index, bot_value in enumerate(value):
            bot_path = f"telegram.bots[{index}]"
            bot_table = _require_mapping(bot_value, bot_path, source_path)
            alias = _require_field(bot_table, "alias", source_path, field_path=f"{bot_path}.alias")
            token = _require_field(bot_table, "token", source_path, field_path=f"{bot_path}.token")
            bot_model_raw = bot_table.get("model")
            if bot_model_raw is not None:
                _validate_type(bot_model_raw, str, f"{bot_path}.model", source_path)
            add_bot(alias, token, f"{bot_path}.alias", f"{bot_path}.token", bot_model=bot_model_raw)
        return bots

    if isinstance(value, dict):
        if not value:
            raise ConfigError(
                f"Invalid value for 'telegram.bots' in {source_path}. "
                "Expected at least one bot entry."
            )

        for alias_key, bot_value in value.items():
            bot_path = f"telegram.bots.{alias_key}"
            bot_table = _require_mapping(bot_value, bot_path, source_path)
            alias = bot_table.get("alias", alias_key)
            token = _require_field(bot_table, "token", source_path, field_path=f"{bot_path}.token")
            bot_model_raw = bot_table.get("model")
            if bot_model_raw is not None:
                _validate_type(bot_model_raw, str, f"{bot_path}.model", source_path)
            add_bot(alias, token, f"{bot_path}.alias", f"{bot_path}.token", bot_model=bot_model_raw)
        return bots

    raise ConfigError(_type_error("telegram.bots", "list[table] | table", source_path))


# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------


def _require_field(
    mapping: dict[str, Any], key: str, source_path: Path, *, field_path: str | None = None
) -> Any:
    value = mapping.get(key, _MISSING)
    name = field_path or key
    if value is _MISSING:
        raise ConfigError(
            f"Missing required config field '{name}' in {source_path}. "
            f"Add '{name}' to the config file."
        )
    return value


def _require_mapping(value: Any, field_path: str, source_path: Path) -> dict[str, Any]:
    if not isinstance(value, dict):
        raise ConfigError(_type_error(field_path, "table", source_path))
    return value


def _validate_type(value: Any, expected_type: type, field_path: str, source_path: Path) -> None:
    if not isinstance(value, expected_type):
        raise ConfigError(_type_error(field_path, expected_type.__name__, source_path))


def _type_error(field_path: str, expected: str, source_path: Path) -> str:
    return f"Invalid type for '{field_path}' in {source_path}. Expected {expected}."


__all__ = [
    "AgentConfig",
    "AutonomyConfig",
    "BotAuthConfig",
    "BotConfig",
    "BotSkillsConfig",
    "CONFIG_FILE",
    "ChannelConfig",
    "Config",
    "ConfigError",
    "DATA_DIR",
    "LOGS_DIR",
    "OTTO_HOME",
    "SESSIONS_DIR",
    "TelegramBotConfig",
    "TelegramConfig",
    "UserConfig",
    "WebConfig",
    "WorkspaceConfig",
    "ensure_dirs",
    "load_config",
    "resolve_user",
    "save_config",
]
