from __future__ import annotations

import copy as copy_mod
from copy import copy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import yaml

PROTO_OPENAI = "openai"
PROTO_DASHSCOPE = "dashscope"
PROTO_OLLAMA = "ollama"
VALID_PROTOS = frozenset({PROTO_OPENAI, PROTO_DASHSCOPE, PROTO_OLLAMA})

_DEFAULT_OPENAI_BASE = "https://api.openai.com/v1"
_DEFAULT_DASHSCOPE_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
_DEFAULT_OLLAMA_BASE = "http://127.0.0.1:11434/v1"

# 主对话与子 Agent 单次用户消息内工具循环次数上限（配置、/agent、agent_settings_update）
MAX_AGENT_TOOL_ROUNDS = 500


@dataclass
class LLMConfig:
    """LLM 源与采样参数（``None`` 表示不显式传参，由接口默认值决定）。"""

    proto: str = PROTO_OPENAI
    base_url: str = ""
    api_key: str = ""
    model: str = "gpt-4o-mini"
    vision_model: str | None = None  # 多模态专用；None 时用 model
    timeout_sec: float = 120.0
    temperature: float | None = None
    top_p: float | None = None
    frequency_penalty: float | None = None
    presence_penalty: float | None = None
    max_output_tokens: int | None = None
    repetition_penalty: float | None = None
    extra_body: dict[str, Any] = field(default_factory=dict)
    max_retries: int = 3  # 失败后额外重试次数；0 表示不重试
    retry_base_delay_sec: float = 1.0  # 退避基数秒（×2^attempt + 抖动）
    # 模型 id → 上下文总窗（tokens）；可与服务端探测值取 min。键 **default** / ***** 为兜底（对所有未单独列出的模型 id）。
    model_context_tokens: dict[str, int] = field(default_factory=dict)


@dataclass
class AgentConfig:
    """主对话与子 Agent 的工具调用最大轮次。"""

    max_tool_rounds: int = 100
    sub_agent_max_tool_rounds: int = 80
    # 载入内存历史的粗算 token 上限（与 chat_serialize 估计、多模态图 8k/张 对齐）；硬上限见 from_dict 2_000_000
    context_budget_tokens: int = 1_000_000
    chat_history_max_messages: int = 800
    # 0=关闭。主对话：TUI 在空闲时注入「定时心跳」；顶层子任务结束也会注入「子任务终态」尽快接续（秒，夹紧 5–3600）。默认 60 便于后台 task_ai 完成后主对话自动检查主收件箱。
    idle_heartbeat_sec: int = 60
    # 子 Agent：仍有 running/pending 子任务且收件箱为空时睡眠再协调（秒；0=立即退出）。默认 30 便于嵌套子任务收尾。
    sub_agent_idle_heartbeat_sec: int = 30
    # 顶层 lifecycle 多条连续到达时合并延迟（秒，夹紧 0–3；0=立即入队）。默认 0.35。
    lifecycle_coalesce_sec: float = 0.35
    # 为 True 时：除 LLM 请求外，Aicd **工具**不得访问非本机回环的外网；默认关。启动可加 --block-external-network 或 AICD_BLOCK_EXTERNAL_NETWORK=1。
    block_external_network: bool = False


def normalize_model_context_tokens_map(raw: Any) -> dict[str, int]:
    """解析 ``llm.modelContextTokens`` / ``model_context_tokens``：仅保留正整数；键为模型 id 或 **default** / *****。"""
    if not isinstance(raw, dict):
        return {}
    out: dict[str, int] = {}
    for k, v in raw.items():
        key = str(k).strip()
        if not key:
            continue
        try:
            iv = int(v)
        except (TypeError, ValueError):
            continue
        if iv > 0:
            out[key] = iv
    return out


def resolve_llm_model_context_window_tokens(
    llm: LLMConfig, model_id: str
) -> int | None:
    """按配置表解析某模型 id 的上下文窗；无表项时返回 ``None``。"""
    m = (model_id or "").strip()
    if not m:
        return None
    table = llm.model_context_tokens or {}
    if not table:
        return None
    if m in table:
        v = table[m]
        return int(v) if v > 0 else None
    ml = m.lower()
    for tk, tv in table.items():
        if str(tk).strip().lower() == ml:
            return int(tv) if tv > 0 else None
    fallback = table.get("default")
    if fallback is None:
        fallback = table.get("*")
    if fallback is not None and str(fallback).strip() != "":
        iv = int(fallback)
        return iv if iv > 0 else None
    return None


def _normalize_proto(raw: str, legacy_provider: str) -> str:
    p = (raw or "").strip().lower()
    if p in VALID_PROTOS:
        return p
    leg = (legacy_provider or "").strip().lower()
    if "dashscope" in leg or "dash_scope" in leg:
        return PROTO_DASHSCOPE
    if "ollama" in leg:
        return PROTO_OLLAMA
    return PROTO_OPENAI


def _llm_fields_from_mapping(llm_raw: dict[str, Any]) -> dict[str, Any]:
    base_url = str(
        llm_raw.get("baseUrl")
        or llm_raw.get("base_url")
        or llm_raw.get("url")
        or ""
    ).strip()
    api_key = str(
        llm_raw.get("apiKey") or llm_raw.get("api_key") or llm_raw.get("key") or ""
    ).strip()
    model = str(
        llm_raw.get("modelId")
        or llm_raw.get("model_id")
        or llm_raw.get("model")
        or "gpt-4o-mini"
    ).strip()
    vm = llm_raw.get("visionModel") or llm_raw.get("vision_model")
    vision_model = str(vm).strip() if vm not in (None, "") else None
    proto = _normalize_proto(
        str(llm_raw.get("proto") or llm_raw.get("PROTO") or ""),
        str(llm_raw.get("provider") or ""),
    )
    timeout = llm_raw.get("timeoutSec")
    if timeout is None:
        timeout = llm_raw.get("timeout_sec")
    timeout_sec = float(timeout if timeout is not None else 120.0)

    def _f(name_camel: str, name_snake: str) -> float | None:
        v = llm_raw.get(name_camel)
        if v is None:
            v = llm_raw.get(name_snake)
        if v is None or v == "":
            return None
        return float(v)

    def _i(name_camel: str, name_snake: str) -> int | None:
        v = llm_raw.get(name_camel)
        if v is None:
            v = llm_raw.get(name_snake)
        if v is None or v == "":
            return None
        return int(v)

    extra = llm_raw.get("extraBody") or llm_raw.get("extra_body")
    extra_body = dict(extra) if isinstance(extra, dict) else {}

    mr = llm_raw.get("maxRetries") if llm_raw.get("maxRetries") is not None else llm_raw.get("max_retries")
    max_retries = int(mr) if mr is not None and str(mr).strip() != "" else 3
    max_retries = max(0, min(20, max_retries))

    rbd = (
        llm_raw.get("retryBaseDelaySec")
        if llm_raw.get("retryBaseDelaySec") is not None
        else llm_raw.get("retry_base_delay_sec")
    )
    retry_base_delay_sec = (
        float(rbd) if rbd is not None and str(rbd).strip() != "" else 1.0
    )
    if retry_base_delay_sec <= 0:
        retry_base_delay_sec = 0.1

    mct_raw = llm_raw.get("modelContextTokens") or llm_raw.get(
        "model_context_tokens"
    )
    model_context_tokens = normalize_model_context_tokens_map(mct_raw)

    return {
        "proto": proto,
        "base_url": base_url,
        "api_key": api_key,
        "model": model,
        "timeout_sec": timeout_sec,
        "temperature": _f("temperature", "temperature"),
        "top_p": _f("topP", "top_p"),
        "frequency_penalty": _f("frequencyPenalty", "frequency_penalty"),
        "presence_penalty": _f("presencePenalty", "presence_penalty"),
        "max_output_tokens": _i("maxOutputTokens", "max_output_tokens"),
        "repetition_penalty": _f("repetitionPenalty", "repetition_penalty"),
        "extra_body": extra_body,
        "vision_model": vision_model,
        "max_retries": max_retries,
        "retry_base_delay_sec": retry_base_delay_sec,
        "model_context_tokens": model_context_tokens,
    }


def _normalize_boot_plugin_mode(raw: str) -> str:
    m = (raw or "full").strip().lower()
    if m in ("full", "safe", "inherit"):
        return m
    return "full"


def _plugins_fields_from_mapping(raw: dict[str, Any]) -> dict[str, Any]:
    if not isinstance(raw, dict):
        return {}
    out: dict[str, Any] = {}
    aph = raw.get("allowPythonHandlers")
    if aph is None:
        aph = raw.get("allow_python_handlers")
    if aph is not None:
        out["allow_python_handlers"] = bool(aph)
    mx = raw.get("maxPlugins")
    if mx is None:
        mx = raw.get("max_plugins")
    if mx is not None:
        out["max_plugins"] = int(mx)
    bk = raw.get("backupKeep")
    if bk is None:
        bk = raw.get("backup_keep")
    if bk is not None:
        out["backup_keep"] = int(bk)
    lr = raw.get("logRetentionDays")
    if lr is None:
        lr = raw.get("log_retention_days")
    if lr is not None:
        out["log_retention_days"] = int(lr)
    return out


def _agent_fields_from_mapping(agent_raw: dict[str, Any]) -> dict[str, Any]:
    if not isinstance(agent_raw, dict):
        return {}
    mr = agent_raw.get("maxToolRounds")
    if mr is None:
        mr = agent_raw.get("max_tool_rounds")
    sr = agent_raw.get("subAgentMaxToolRounds")
    if sr is None:
        sr = agent_raw.get("sub_agent_max_tool_rounds")
    out: dict[str, Any] = {}
    if mr is not None:
        out["max_tool_rounds"] = int(mr)
    if sr is not None:
        out["sub_agent_max_tool_rounds"] = int(sr)
    cb = agent_raw.get("contextBudgetTokens")
    if cb is None:
        cb = agent_raw.get("context_budget_tokens")
    if cb is not None:
        out["context_budget_tokens"] = int(cb)
    hm = agent_raw.get("chatHistoryMaxMessages")
    if hm is None:
        hm = agent_raw.get("chat_history_max_messages")
    if hm is not None:
        out["chat_history_max_messages"] = int(hm)
    ih = agent_raw.get("idleHeartbeatSec")
    if ih is None:
        ih = agent_raw.get("idle_heartbeat_sec")
    if ih is not None:
        out["idle_heartbeat_sec"] = int(ih)
    sah = agent_raw.get("subAgentIdleHeartbeatSec")
    if sah is None:
        sah = agent_raw.get("sub_agent_idle_heartbeat_sec")
    if sah is not None:
        out["sub_agent_idle_heartbeat_sec"] = int(sah)
    lc = agent_raw.get("lifecycleCoalesceSec")
    if lc is None:
        lc = agent_raw.get("lifecycle_coalesce_sec")
    if lc is not None and str(lc).strip() != "":
        out["lifecycle_coalesce_sec"] = float(lc)
    ben = agent_raw.get("blockExternalNetwork")
    if ben is None:
        ben = agent_raw.get("block_external_network")
    if ben is not None and str(ben).strip() != "":
        out["block_external_network"] = bool(ben)
    return out


def effective_llm_config(cfg: LLMConfig) -> LLMConfig:
    """按 proto 补全默认 base_url / 占位 api_key，供 OpenAI 兼容客户端使用。"""
    out = copy(cfg)
    proto = out.proto.strip().lower()
    if proto not in VALID_PROTOS:
        proto = PROTO_OPENAI
    out.proto = proto
    base = (out.base_url or "").strip().rstrip("/")
    key = out.api_key or ""
    if proto == PROTO_OPENAI:
        if not base:
            base = _DEFAULT_OPENAI_BASE
    elif proto == PROTO_DASHSCOPE:
        if not base:
            base = _DEFAULT_DASHSCOPE_BASE
    elif proto == PROTO_OLLAMA:
        if not base:
            base = _DEFAULT_OLLAMA_BASE
        if not key:
            key = "ollama"
    out.base_url = base
    out.api_key = key
    return out


def _default_allowed_roots() -> list[str]:
    return [str(Path.home()), "."]


@dataclass
class PathPolicyConfig:
    full_disk: bool = True
    allowed_roots: list[str] = field(default_factory=_default_allowed_roots)
    max_read_bytes: int = 2_000_000
    max_search_file_bytes: int = 5_000_000


@dataclass
class PluginsConfig:
    """HOME/plugins 扩展：可选 Python 处理器、数量与备份保留策略。"""

    allow_python_handlers: bool = False
    max_plugins: int = 64
    backup_keep: int = 20
    # 各插件目录下 logs/*.log 保留天数（按 UTC 日期分文件）
    log_retention_days: int = 7
    # 启动时是否加载插件：full=常规；safe=永不自动加载；inherit=看 AICD_PLUGIN_POLICY 与嵌套
    boot_plugin_mode: str = "full"
    # 嵌套调用深度告警上限（超过仍允许启动，但 session_log 记录；spawn/host 可据此拒绝）
    max_invocation_depth: int = 8
    # 是否允许插件文档中描述的「再启动 Aicd 子进程」能力（具体 spawn 由工具或 Host 实现检查）
    allow_nested_aicd_from_plugins: bool = False


@dataclass
class WebFetchIntegrationConfig:
    """联网抓取默认值（**web_fetch** 工具）；正文抽取依赖可选包 trafilatura。"""

    # 空串则由运行时拼 ``AICD/<version>``
    user_agent: str = ""
    max_response_bytes: int = 1_500_000
    default_timeout_sec: float = 45.0


@dataclass
class GitHubIntegrationConfig:
    """GitHub REST（**github_api**）；``token`` 可用环境变量 GITHUB_TOKEN / GH_TOKEN 覆盖。"""

    api_base_url: str = "https://api.github.com"
    token: str = ""
    accept: str = "application/vnd.github+json"


@dataclass
class IntegrationsConfig:
    web_fetch: WebFetchIntegrationConfig = field(
        default_factory=WebFetchIntegrationConfig
    )
    github: GitHubIntegrationConfig = field(default_factory=GitHubIntegrationConfig)


def _integrations_fields_from_mapping(raw: dict[str, Any]) -> dict[str, Any]:
    if not isinstance(raw, dict):
        return {}
    wf_raw = raw.get("webFetch") or raw.get("web_fetch") or {}
    gh_raw = raw.get("github") or {}
    out: dict[str, Any] = {}
    if isinstance(wf_raw, dict):
        ua = wf_raw.get("userAgent") or wf_raw.get("user_agent")
        if ua is not None:
            out["web_user_agent"] = str(ua).strip()
        mrb = wf_raw.get("maxResponseBytes")
        if mrb is None:
            mrb = wf_raw.get("max_response_bytes")
        if mrb is not None:
            out["web_max_response_bytes"] = int(mrb)
        dts = wf_raw.get("defaultTimeoutSec")
        if dts is None:
            dts = wf_raw.get("default_timeout_sec")
        if dts is not None:
            out["web_default_timeout_sec"] = float(dts)
    if isinstance(gh_raw, dict):
        bu = gh_raw.get("apiBaseUrl") or gh_raw.get("api_base_url")
        if bu is not None:
            out["gh_api_base_url"] = str(bu).strip().rstrip("/")
        tok = gh_raw.get("token") or gh_raw.get("apiToken") or gh_raw.get("api_token")
        if tok is not None:
            out["gh_token"] = str(tok).strip()
        acc = gh_raw.get("accept")
        if acc is not None:
            out["gh_accept"] = str(acc).strip()
    return out


@dataclass
class AppConfig:
    llm: LLMConfig = field(default_factory=LLMConfig)
    agent: AgentConfig = field(default_factory=AgentConfig)
    paths: PathPolicyConfig = field(default_factory=PathPolicyConfig)
    plugins: PluginsConfig = field(default_factory=PluginsConfig)
    integrations: IntegrationsConfig = field(default_factory=IntegrationsConfig)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> AppConfig:
        llm_raw = data.get("llm") or {}
        agent_raw = data.get("agent") or {}
        paths_raw = data.get("paths") or {}
        plugins_raw = data.get("plugins") or {}
        integrations_raw = data.get("integrations") or {}
        default_paths = PathPolicyConfig()
        default_agent = AgentConfig()
        default_plugins = PluginsConfig()
        default_web = WebFetchIntegrationConfig()
        default_gh = GitHubIntegrationConfig()
        roots = paths_raw.get("allowed_roots")
        if not roots:
            roots = default_paths.allowed_roots
        lf = _llm_fields_from_mapping(llm_raw if isinstance(llm_raw, dict) else {})
        af = _agent_fields_from_mapping(
            agent_raw if isinstance(agent_raw, dict) else {}
        )
        pf = _plugins_fields_from_mapping(
            plugins_raw if isinstance(plugins_raw, dict) else {}
        )
        intf = _integrations_fields_from_mapping(
            integrations_raw if isinstance(integrations_raw, dict) else {}
        )
        return cls(
            llm=LLMConfig(
                proto=str(lf["proto"]),
                base_url=str(lf["base_url"]),
                api_key=str(lf["api_key"]),
                model=str(lf["model"]),
                vision_model=lf.get("vision_model"),
                timeout_sec=float(lf["timeout_sec"]),
                temperature=lf.get("temperature"),
                top_p=lf.get("top_p"),
                frequency_penalty=lf.get("frequency_penalty"),
                presence_penalty=lf.get("presence_penalty"),
                max_output_tokens=lf.get("max_output_tokens"),
                repetition_penalty=lf.get("repetition_penalty"),
                extra_body=dict(lf.get("extra_body") or {}),
                max_retries=max(
                    0,
                    min(20, int(lf.get("max_retries", 3))),
                ),
                retry_base_delay_sec=max(
                    0.1,
                    float(lf.get("retry_base_delay_sec", 1.0)),
                ),
                model_context_tokens=dict(
                    lf.get("model_context_tokens") or {}
                ),
            ),
            agent=AgentConfig(
                max_tool_rounds=max(
                    1,
                    min(
                        MAX_AGENT_TOOL_ROUNDS,
                        int(af.get("max_tool_rounds", default_agent.max_tool_rounds)),
                    ),
                ),
                sub_agent_max_tool_rounds=max(
                    1,
                    min(
                        MAX_AGENT_TOOL_ROUNDS,
                        int(
                            af.get(
                                "sub_agent_max_tool_rounds",
                                default_agent.sub_agent_max_tool_rounds,
                            )
                        ),
                    ),
                ),
                context_budget_tokens=max(
                    4096,
                    min(
                        2_000_000,
                        int(
                            af.get(
                                "context_budget_tokens",
                                default_agent.context_budget_tokens,
                            )
                        ),
                    ),
                ),
                chat_history_max_messages=max(
                    10,
                    min(
                        20_000,
                        int(
                            af.get(
                                "chat_history_max_messages",
                                default_agent.chat_history_max_messages,
                            )
                        ),
                    ),
                ),
                idle_heartbeat_sec=max(
                    0,
                    min(
                        3600,
                        int(af.get("idle_heartbeat_sec", default_agent.idle_heartbeat_sec)),
                    ),
                ),
                sub_agent_idle_heartbeat_sec=max(
                    0,
                    min(
                        3600,
                        int(
                            af.get(
                                "sub_agent_idle_heartbeat_sec",
                                default_agent.sub_agent_idle_heartbeat_sec,
                            )
                        ),
                    ),
                ),
                lifecycle_coalesce_sec=max(
                    0.0,
                    min(
                        3.0,
                        float(
                            af.get(
                                "lifecycle_coalesce_sec",
                                default_agent.lifecycle_coalesce_sec,
                            )
                        ),
                    ),
                ),
                block_external_network=bool(
                    af.get(
                        "block_external_network",
                        default_agent.block_external_network,
                    )
                ),
            ),
            paths=PathPolicyConfig(
                full_disk=bool(paths_raw.get("full_disk", True)),
                allowed_roots=list(roots),
                max_read_bytes=int(
                    paths_raw.get("max_read_bytes", default_paths.max_read_bytes)
                ),
                max_search_file_bytes=int(
                    paths_raw.get(
                        "max_search_file_bytes", default_paths.max_search_file_bytes
                    )
                ),
            ),
            plugins=PluginsConfig(
                allow_python_handlers=bool(
                    pf.get("allow_python_handlers", default_plugins.allow_python_handlers)
                ),
                max_plugins=max(
                    1,
                    min(500, int(pf.get("max_plugins", default_plugins.max_plugins))),
                ),
                backup_keep=max(
                    1,
                    min(500, int(pf.get("backup_keep", default_plugins.backup_keep))),
                ),
                log_retention_days=max(
                    1,
                    min(
                        90,
                        int(
                            pf.get(
                                "log_retention_days",
                                default_plugins.log_retention_days,
                            )
                        ),
                    ),
                ),
                boot_plugin_mode=_normalize_boot_plugin_mode(
                    str(
                        pf.get("boot_plugin_mode", default_plugins.boot_plugin_mode)
                        or "full"
                    )
                ),
                max_invocation_depth=max(
                    0,
                    min(
                        100,
                        int(
                            pf.get(
                                "max_invocation_depth",
                                default_plugins.max_invocation_depth,
                            )
                        ),
                    ),
                ),
                allow_nested_aicd_from_plugins=bool(
                    pf.get(
                        "allow_nested_aicd_from_plugins",
                        default_plugins.allow_nested_aicd_from_plugins,
                    )
                ),
            ),
            integrations=IntegrationsConfig(
                web_fetch=WebFetchIntegrationConfig(
                    user_agent=str(
                        intf.get("web_user_agent", default_web.user_agent) or ""
                    ),
                    max_response_bytes=max(
                        4096,
                        min(
                            8_000_000,
                            int(
                                intf.get(
                                    "web_max_response_bytes",
                                    default_web.max_response_bytes,
                                )
                            ),
                        ),
                    ),
                    default_timeout_sec=max(
                        5.0,
                        min(
                            600.0,
                            float(
                                intf.get(
                                    "web_default_timeout_sec",
                                    default_web.default_timeout_sec,
                                )
                            ),
                        ),
                    ),
                ),
                github=GitHubIntegrationConfig(
                    api_base_url=str(
                        intf.get("gh_api_base_url", default_gh.api_base_url)
                        or default_gh.api_base_url
                    ).rstrip("/"),
                    token=str(intf.get("gh_token", default_gh.token) or ""),
                    accept=str(intf.get("gh_accept", default_gh.accept) or default_gh.accept),
                ),
            ),
        )

    def to_dict(self) -> dict[str, Any]:
        llm_d: dict[str, Any] = {
            "proto": self.llm.proto,
            "baseUrl": self.llm.base_url,
            "apiKey": self.llm.api_key,
            "modelId": self.llm.model,
            "timeoutSec": self.llm.timeout_sec,
        }
        if self.llm.temperature is not None:
            llm_d["temperature"] = self.llm.temperature
        if self.llm.top_p is not None:
            llm_d["topP"] = self.llm.top_p
        if self.llm.frequency_penalty is not None:
            llm_d["frequencyPenalty"] = self.llm.frequency_penalty
        if self.llm.presence_penalty is not None:
            llm_d["presencePenalty"] = self.llm.presence_penalty
        if self.llm.max_output_tokens is not None:
            llm_d["maxOutputTokens"] = self.llm.max_output_tokens
        if self.llm.repetition_penalty is not None:
            llm_d["repetitionPenalty"] = self.llm.repetition_penalty
        if self.llm.extra_body:
            llm_d["extraBody"] = dict(self.llm.extra_body)
        if self.llm.vision_model:
            llm_d["visionModel"] = self.llm.vision_model
        if self.llm.max_retries != 3:
            llm_d["maxRetries"] = self.llm.max_retries
        if self.llm.retry_base_delay_sec != 1.0:
            llm_d["retryBaseDelaySec"] = self.llm.retry_base_delay_sec
        if self.llm.model_context_tokens:
            llm_d["modelContextTokens"] = dict(self.llm.model_context_tokens)
        agent_d: dict[str, Any] = {
            "maxToolRounds": self.agent.max_tool_rounds,
            "subAgentMaxToolRounds": self.agent.sub_agent_max_tool_rounds,
            "contextBudgetTokens": self.agent.context_budget_tokens,
            "chatHistoryMaxMessages": self.agent.chat_history_max_messages,
            "idleHeartbeatSec": self.agent.idle_heartbeat_sec,
            "subAgentIdleHeartbeatSec": self.agent.sub_agent_idle_heartbeat_sec,
            "lifecycleCoalesceSec": self.agent.lifecycle_coalesce_sec,
        }
        if self.agent.block_external_network:
            agent_d["blockExternalNetwork"] = True
        return {
            "llm": llm_d,
            "agent": agent_d,
            "paths": {
                "full_disk": self.paths.full_disk,
                "allowed_roots": self.paths.allowed_roots,
                "max_read_bytes": self.paths.max_read_bytes,
                "max_search_file_bytes": self.paths.max_search_file_bytes,
            },
            "plugins": {
                "allowPythonHandlers": self.plugins.allow_python_handlers,
                "maxPlugins": self.plugins.max_plugins,
                "backupKeep": self.plugins.backup_keep,
                "logRetentionDays": self.plugins.log_retention_days,
                "bootPluginMode": self.plugins.boot_plugin_mode,
                "maxInvocationDepth": self.plugins.max_invocation_depth,
                "allowNestedAicdFromPlugins": self.plugins.allow_nested_aicd_from_plugins,
            },
            "integrations": {
                "webFetch": {
                    "userAgent": self.integrations.web_fetch.user_agent,
                    "maxResponseBytes": self.integrations.web_fetch.max_response_bytes,
                    "defaultTimeoutSec": self.integrations.web_fetch.default_timeout_sec,
                },
                "github": {
                    "apiBaseUrl": self.integrations.github.api_base_url,
                    "token": self.integrations.github.token,
                    "accept": self.integrations.github.accept,
                },
            },
        }


def load_config(path: Path) -> AppConfig:
    if not path.is_file():
        return AppConfig()
    with path.open("r", encoding="utf-8") as f:
        raw = yaml.safe_load(f) or {}
    return AppConfig.from_dict(raw if isinstance(raw, dict) else {})


def deep_merge_missing_keys(dst: dict[str, Any], defaults: dict[str, Any]) -> bool:
    """仅补缺失键；已有键（含显式 ``0``、空串）不覆盖。嵌套 dict 递归补子键。"""
    changed = False
    for k, v in defaults.items():
        if k not in dst:
            dst[k] = copy_mod.deepcopy(v)
            changed = True
        elif isinstance(v, dict) and isinstance(dst.get(k), dict):
            if deep_merge_missing_keys(dst[k], v):
                changed = True
        elif isinstance(v, dict) and not isinstance(dst.get(k), dict):
            dst[k] = copy_mod.deepcopy(v)
            changed = True
    return changed


def apply_missing_config_defaults_to_file(path: Path) -> AppConfig:
    """
    将当前代码向量的默认 ``AppConfig.to_dict()`` 与磁盘 YAML 合并后写回。
    用于旧配置自动获得新增节（如 **integrations**）及嵌套默认值。
    """
    fresh = AppConfig()
    if not path.is_file():
        save_config(path, fresh)
        return fresh
    with path.open("r", encoding="utf-8") as f:
        raw = yaml.safe_load(f) or {}
    if not isinstance(raw, dict):
        raw = {}
    default_dict = fresh.to_dict()
    if deep_merge_missing_keys(raw, default_dict):
        merged = AppConfig.from_dict(raw)
        save_config(path, merged)
        return merged
    return AppConfig.from_dict(raw)


def apply_agent_heartbeat_defaults_to_config_file(path: Path, cfg: AppConfig) -> AppConfig:
    """若 ``config.yaml`` 缺字段则补默认。现与 ``apply_missing_config_defaults_to_file`` 统一（全量模式）。"""
    _ = cfg
    return apply_missing_config_defaults_to_file(path)


def save_config(path: Path, cfg: AppConfig) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        yaml.safe_dump(cfg.to_dict(), f, allow_unicode=True, sort_keys=False)
