from __future__ import annotations

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 + 抖动）


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

    max_tool_rounds: int = 100
    sub_agent_max_tool_rounds: int = 80
    context_budget_tokens: int = 128000
    chat_history_max_messages: int = 400
    # 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


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

    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,
    }


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)
    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 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)

    @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 {}
        default_paths = PathPolicyConfig()
        default_agent = AgentConfig()
        default_plugins = PluginsConfig()
        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 {}
        )
        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)),
                ),
            ),
            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,
                    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,
                            )
                        ),
                    ),
                ),
            ),
            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,
                    )
                ),
            ),
        )

    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
        return {
            "llm": llm_d,
            "agent": {
                "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,
            },
            "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,
            },
        }


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 apply_agent_heartbeat_defaults_to_config_file(path: Path, cfg: AppConfig) -> AppConfig:
    """若 ``config.yaml`` 的 ``agent`` 下未声明主/子心跳键，则补写当前内存中的默认值并保存。

    用户已显式写入 ``0`` 的键不会被覆盖。仅补缺失项，便于旧配置自动获得接续行为。
    """
    if not path.is_file():
        return cfg
    with path.open("r", encoding="utf-8") as f:
        raw = yaml.safe_load(f) or {}
    if not isinstance(raw, dict):
        return cfg
    agent_raw = raw.get("agent")
    if not isinstance(agent_raw, dict):
        agent_raw = {}
        raw["agent"] = agent_raw
    changed = False
    if (
        "idleHeartbeatSec" not in agent_raw
        and "idle_heartbeat_sec" not in agent_raw
    ):
        agent_raw["idleHeartbeatSec"] = int(cfg.agent.idle_heartbeat_sec)
        changed = True
    if (
        "subAgentIdleHeartbeatSec" not in agent_raw
        and "sub_agent_idle_heartbeat_sec" not in agent_raw
    ):
        agent_raw["subAgentIdleHeartbeatSec"] = int(
            cfg.agent.sub_agent_idle_heartbeat_sec
        )
        changed = True
    if (
        "lifecycleCoalesceSec" not in agent_raw
        and "lifecycle_coalesce_sec" not in agent_raw
    ):
        agent_raw["lifecycleCoalesceSec"] = float(cfg.agent.lifecycle_coalesce_sec)
        changed = True
    if not changed:
        return cfg
    merged = AppConfig.from_dict(raw)
    save_config(path, merged)
    return merged


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)
