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）。
    idle_heartbeat_sec: int = 0
    # 子 Agent：本任务仍有 running/pending 子任务且收件箱为空时，睡眠后再拉收件箱或注入协调心跳（秒，夹紧 5–3600；0=保持原行为立即退出）。
    sub_agent_idle_heartbeat_sec: int = 0


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 _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)
    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 AppConfig:
    llm: LLMConfig = field(default_factory=LLMConfig)
    agent: AgentConfig = field(default_factory=AgentConfig)
    paths: PathPolicyConfig = field(default_factory=PathPolicyConfig)

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

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


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