"""多厂商 LLM：供应商表从 etc/llm.yaml 加载（密钥仍走环境变量）。"""

from __future__ import annotations

import base64
import json
import os
from functools import lru_cache
from pathlib import Path
from typing import Any

import httpx
import yaml

_DEFAULT_CONFIG_CANDIDATES = (
    Path(__file__).resolve().parent / "etc" / "llm.yaml",
    Path("/app/etc/llm.yaml"),
)


def _config_path() -> Path:
    for key in ("AI_CONFIG_PATH", "LLM_CONFIG_PATH"):
        raw = (os.getenv(key) or "").strip()
        if raw:
            return Path(raw)
    for p in _DEFAULT_CONFIG_CANDIDATES:
        if p.is_file():
            return p
    return _DEFAULT_CONFIG_CANDIDATES[0]


@lru_cache(maxsize=1)
def load_ai_config() -> dict[str, Any]:
    path = _config_path()
    if not path.is_file():
        raise FileNotFoundError(
            f"AI 配置文件不存在: {path}（可用 AI_CONFIG_PATH / LLM_CONFIG_PATH 指定）"
        )
    with path.open("r", encoding="utf-8") as f:
        data = yaml.safe_load(f) or {}
    if not isinstance(data, dict):
        raise ValueError(f"AI 配置格式错误（须为 mapping）: {path}")
    providers = data.get("providers") or {}
    if not isinstance(providers, dict) or not providers:
        raise ValueError(f"AI 配置缺少 providers: {path}")
    return data


def reload_ai_config() -> dict[str, Any]:
    """供热加载 / 测试清空缓存。"""
    load_ai_config.cache_clear()
    return load_ai_config()


def get_providers() -> dict[str, dict[str, Any]]:
    raw = load_ai_config().get("providers") or {}
    out: dict[str, dict[str, Any]] = {}
    for pid, meta in raw.items():
        if not isinstance(meta, dict):
            continue
        out[str(pid).strip().lower()] = dict(meta)
    return out


# 兼容旧代码：PROVIDERS 为属性式访问，始终读当前配置
class _ProvidersProxy(dict):
    def _sync(self) -> None:
        self.clear()
        self.update(get_providers())

    def __contains__(self, key: object) -> bool:  # type: ignore[override]
        self._sync()
        return dict.__contains__(self, key)

    def __getitem__(self, key: str) -> dict[str, Any]:
        self._sync()
        return dict.__getitem__(self, key)

    def get(self, key: str, default: Any = None) -> Any:  # type: ignore[override]
        self._sync()
        return dict.get(self, key, default)

    def items(self):  # type: ignore[override]
        self._sync()
        return dict.items(self)

    def keys(self):  # type: ignore[override]
        self._sync()
        return dict.keys(self)

    def values(self):  # type: ignore[override]
        self._sync()
        return dict.values(self)


PROVIDERS: dict[str, dict[str, Any]] = _ProvidersProxy()  # type: ignore[assignment]


def _default_provider_id() -> str:
    cfg = load_ai_config()
    return (
        (os.getenv("LLM_PROVIDER") or "").strip().lower()
        or str(cfg.get("default_provider") or "deepseek").strip().lower()
        or "deepseek"
    )


def _aliases() -> dict[str, str]:
    raw = load_ai_config().get("aliases") or {}
    return {str(k).strip().lower(): str(v).strip().lower() for k, v in raw.items()}


def _vision_cfg() -> dict[str, Any]:
    v = load_ai_config().get("vision") or {}
    return v if isinstance(v, dict) else {}


def _normalize_pid(provider_id: str | None, *, aliases: dict[str, str] | None = None) -> str:
    pid = (provider_id or "").strip().lower()
    amap = aliases if aliases is not None else _aliases()
    if pid in amap:
        pid = amap[pid]
    return pid


def list_providers() -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for pid, meta in get_providers().items():
        key_env = meta.get("api_key_env") or ""
        configured = True
        if key_env:
            configured = bool(os.getenv(str(key_env), "").strip())
        out.append(
            {
                "id": pid,
                "label": meta.get("label") or pid,
                "configured": configured,
                "default_model": meta.get("default_model") or "",
                "models": meta.get("models") or [],
                "supports_vision": bool(meta.get("supports_vision")),
            }
        )
    return out


def resolve_provider(provider_id: str | None, model: str | None = None) -> tuple[str, dict[str, Any], str]:
    """返回 (provider_id, meta, model_name)。"""
    providers = get_providers()
    pid = _normalize_pid(provider_id) or _default_provider_id()
    if pid not in providers:
        fallback = _default_provider_id()
        pid = fallback if fallback in providers else next(iter(providers))
    meta = dict(providers[pid])
    # 单供应商可被环境变量覆盖 base_url（如 DASHSCOPE_BASE_URL）
    env_base_key = f"{pid.upper()}_BASE_URL"
    env_base = (os.getenv(env_base_key) or "").strip()
    if env_base:
        meta["base_url"] = env_base
    elif pid == "dashscope":
        # 兼容旧变量名
        legacy = (os.getenv("DASHSCOPE_BASE_URL") or "").strip()
        if legacy:
            meta["base_url"] = legacy
    vision = _vision_cfg()
    if pid == (str(vision.get("provider") or "dashscope").strip().lower()):
        if (os.getenv("VISION_MODEL") or "").strip() and not (model or "").strip():
            meta["default_model"] = os.getenv("VISION_MODEL", "").strip()
    model_name = (model or "").strip() or str(meta.get("default_model") or "")
    return pid, meta, model_name


def resolve_vision_provider(
    provider_id: str | None = None,
    model: str | None = None,
) -> tuple[str, dict[str, Any], str]:
    """优先配置 vision / 环境变量 VISION_*；禁止把纯文本模型名传给视觉通道。"""
    providers = get_providers()
    vision = _vision_cfg()
    vision_aliases = {
        **_aliases(),
        **{
            str(k).strip().lower(): str(v).strip().lower()
            for k, v in (vision.get("aliases") or {}).items()
        },
    }
    env_pid = (os.getenv("VISION_PROVIDER") or "").strip().lower()
    cfg_pid = str(vision.get("provider") or "").strip().lower()
    vision_pid = _normalize_pid(env_pid or provider_id or cfg_pid, aliases=vision_aliases)

    env_model = (os.getenv("VISION_MODEL") or "").strip()
    cfg_model = str(vision.get("model") or "").strip()
    passed = (model or "").strip()
    hints = [str(h).lower() for h in (load_ai_config().get("vision_model_hints") or [])]
    if not hints:
        hints = ["vl", "vision", "qwen3", "qwen-vl", "gpt-4o", "gemini"]
    vision_like = bool(
        passed
        and any(k in passed.lower() for k in hints)
        and "deepseek" not in passed.lower()
        and passed.lower() != "chat"
    )
    vision_model = env_model or cfg_model or (passed if vision_like else "") or ""

    def _has_key(pid: str) -> bool:
        meta = providers.get(pid) or {}
        env = meta.get("api_key_env") or ""
        return bool(env) and bool(os.getenv(str(env), "").strip())

    # 文本供应商无视觉时，回退到配置的视觉供应商（若已配密钥）
    if vision_pid in providers and not providers[vision_pid].get("supports_vision"):
        fallback = _normalize_pid(cfg_pid or "dashscope", aliases=vision_aliases)
        if fallback in providers and providers[fallback].get("supports_vision") and _has_key(fallback):
            vision_pid = fallback
        else:
            vision_pid = ""

    if vision_pid and vision_pid in providers and providers[vision_pid].get("supports_vision"):
        return resolve_provider(vision_pid, vision_model or None)

    pid, meta, _ = resolve_provider(provider_id, None)
    if meta.get("supports_vision"):
        return resolve_provider(pid, vision_model or None)

    fallback = _normalize_pid(cfg_pid or "dashscope", aliases=vision_aliases)
    if fallback in providers and providers[fallback].get("supports_vision") and _has_key(fallback):
        return resolve_provider(fallback, vision_model or None)
    return pid, meta, vision_model or str(meta.get("default_model") or "")


def _api_key(meta: dict[str, Any]) -> str:
    env = meta.get("api_key_env") or ""
    if not env:
        return ""
    return os.getenv(str(env), "").strip()


def enhance_blueprint_with_llm(
    draft: dict[str, Any],
    prompt: str,
    excel_summary: str,
    provider_id: str | None = None,
    model: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
    pid, meta, model_name = resolve_provider(provider_id, model)
    if pid == "heuristic":
        return draft, ["使用本地启发式蓝图（未调用大模型）"]

    api_key = _api_key(meta)
    if not api_key:
        env = meta.get("api_key_env") or ""
        return draft, [f"未配置 {env}，回退启发式蓝图"]

    base = (meta.get("base_url") or "").rstrip("/")
    system = (
        "你是低代码 CMS 蓝图助手。只能输出 JSON 对象，字段必须兼容现有 draft 结构。"
        "field.type 仅允许: string,text,int,bigint,decimal,boolean,date,datetime,enum,json,file_ref。"
        "所有 name/slug/table/path 必须是 snake_case（小写字母数字下划线），禁止驼峰与连字符。"
        "可优化 meta.name/description、field.label、pages.title；不要发明新 type，不要输出 SQL。"
        "优先在给定 draft 上微调，保留 entities/fields/apis/pages 结构完整。"
        "若 draft.meta.ui_preset 或页面 layout.preset 为 screenshot_faithful，必须原样保留，"
        "禁止改成通用后台壳；用户未声明修改的标题/分区/筛选/图表/功能按键文案不得擅自更换。"
        "若 layout.action_labels 已给出按钮原文，必须保留；actions 顺序也尽量保留。"
        "页面形态须跟随用户需求与截图还原，禁止所有应用统一成同一种列表模板；"
        "禁止把任意业务改写成固定行业文案（如铁路沉降），除非用户或截图本身如此。"
    )
    user = {
        "user_prompt": prompt,
        "excel_summary": excel_summary,
        "draft": draft,
        "instruction": "返回完整 draft JSON（不要 markdown）",
    }
    body: dict[str, Any] = {
        "model": model_name,
        "temperature": 0.2,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": json.dumps(user, ensure_ascii=False)},
        ],
    }
    if meta.get("json_mode"):
        body["response_format"] = {"type": "json_object"}

    try:
        with httpx.Client(timeout=90.0) as client:
            resp = client.post(
                f"{base}/chat/completions",
                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
                json=body,
            )
            resp.raise_for_status()
            content = resp.json()["choices"][0]["message"]["content"]
            text = content.strip()
            if text.startswith("```"):
                text = text.strip("`")
                if text.startswith("json"):
                    text = text[4:].lstrip()
            enhanced = json.loads(text)
            if "version" in enhanced and "entities" in enhanced:
                enhanced.setdefault("storage", draft.get("storage"))
                enhanced.setdefault("security", draft.get("security"))
                enhanced.setdefault("apis", draft.get("apis"))
                return enhanced, [f"已使用 {meta.get('label', pid)}/{model_name} 润色蓝图"]
            if "draft" in enhanced and isinstance(enhanced["draft"], dict):
                d = enhanced["draft"]
                d.setdefault("storage", draft.get("storage"))
                d.setdefault("security", draft.get("security"))
                return d, [f"已使用 {meta.get('label', pid)}/{model_name} 润色蓝图"]
            return draft, ["LLM 返回结构无效，回退启发式"]
    except Exception as e:  # noqa: BLE001
        return draft, [f"{meta.get('label', pid)} 调用失败，回退启发式: {e}"]


_VISION_PROMPT = """这些是目标业务系统的界面截图。请用中文输出「可直接写入领域说明」的结构化要点，务必逐字抄录可见文案，不要臆造。

多图规则（重要）：
- 若多张图是**同一页面**的不同状态（展开/收起某一区、滚动到不同里程、筛选前后、空表/有数据），视为**一个界面**，合并描述，不要当成多个互斥布局。
- 以「信息最完整」的那张为主结构（例如展开后能看见主图+监督条+表）；收起态只补充「可折叠/可滚动」交互，不要因此删掉展开态才有的分区。
- 若文案冲突，优先采用更清晰、更完整的一帧；并在备注里写「同页多状态」。
- 仅当明显是不同页面（不同顶栏系统名/不同主导航页）时，才分页面描述。

按下列小标题组织（若没有的写「未见」）：
### 顶栏
- 左侧平台抬头、中间系统名、右侧链接原文
### 次级导航
- 页签原文（顿号分隔）
### 筛选条
- 工程/业务上下文原文
- 统计文案（个数类，原文照抄）
- 单选选项原文（全部 / …）
- 下拉标签原文
- 右侧提示原文（若有）
### 主图区
- 左侧竖排标签原文
- 图例系列名原文
- 图表类型与坐标大致含义
### 状态条/副图
- 左侧竖排标签原文
- 形态：单色格 / 双段条 / 其它（据实描述）
- 条上是否显示类目文字
- 是否可折叠/收起（若另一张图为收起态请注明）
### 表格区
- 左侧竖排标签、表头横标题、列名原文
### 操作按键
- 格式：操作：……、……
### 多图关系（若有多张）
- 一句话说明：同页展开/收起，或不同页

用户补充需求：{prompt}
"""


def understand_images(
    prompt: str,
    images: list[tuple[str, bytes, str]],
    provider_id: str | None = None,
    model: str | None = None,
) -> tuple[str, list[str]]:
    """返回补充描述 + warnings。无视觉能力时回退 VISION_PROVIDER / 配置中的视觉供应商。"""
    warnings: list[str] = []
    if not images:
        return "", warnings

    names = [n for n, _, _ in images]
    hint = (
        f"用户上传了界面截图: {', '.join(names)}。"
        "默认按截图原样还原（screenshot_faithful）：顶栏、页签、筛选、工具栏功能按键原文、图表分区、表格列均与原图一致；"
        "仅用户文字明确要求修改的部分可调整。"
    )

    pid, meta, model_name = resolve_vision_provider(provider_id, model)
    if not meta.get("supports_vision"):
        warnings.append(
            f"当前视觉通道 {pid or '无'} 不支持看图；请在 etc/llm.yaml 配置 vision.provider 并设置对应 API Key"
        )
        return hint, warnings

    api_key = _api_key(meta)
    if not api_key:
        env = meta.get("api_key_env") or ""
        warnings.append(f"未配置 {env}，截图仅作占位提示")
        return hint, warnings

    base = (meta.get("base_url") or "").rstrip("/")
    content: list[dict[str, Any]] = [
        {"type": "text", "text": _VISION_PROMPT.format(prompt=prompt or "（无额外文字）")}
    ]
    for name, raw, mime in images:
        b64 = base64.b64encode(raw).decode("ascii")
        content.append(
            {
                "type": "image_url",
                "image_url": {"url": f"data:{mime or 'image/png'};base64,{b64}"},
            }
        )
        _ = name

    body = {
        "model": model_name,
        "temperature": 0.1,
        "messages": [{"role": "user", "content": content}],
    }
    try:
        with httpx.Client(timeout=120.0) as client:
            resp = client.post(
                f"{base}/chat/completions",
                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
                json=body,
            )
            resp.raise_for_status()
            text = (resp.json()["choices"][0]["message"]["content"] or "").strip()
            if not text:
                return hint, warnings + ["视觉模型返回空内容"]
            return f"{hint}\n\n【截图理解】\n{text}", warnings + [
                f"已用 {meta.get('label', pid)}/{model_name} 理解截图"
            ]
    except Exception as e:  # noqa: BLE001
        warnings.append(f"看图失败: {e}")
        return hint, warnings
