from app.core.credential import encrypt_secret from app.core.app_security import scan_market_app from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import and_, desc, asc, func from pydantic import BaseModel, Field from typing import Optional import uuid from datetime import datetime import httpx from urllib.parse import urlparse, urljoin import re import json import os from ..database import get_db from ..models import ( User, MarketApp, AppPurchase, AppReview, AppInstallation, Order, AdBanner, MCPServer, ModelProvider, gen_uuid, MarketAppVersion ) from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional from ..middleware.log_service import LogService from ..capability_registry import resolve_auto_model as _resolve_auto_model from app.api.admin.mcp_admin import _test_mcp_server, parse_mcp_config router = APIRouter() def _extract_url_from_text(text: Optional[str]) -> Optional[str]: """Extract first http/https URL from text.""" if not text: return None match = re.search(r"https?://[^\s\"'<>\)\]\}]+", text) return match.group(0) if match else None def _skill_http_proxy(): """容器内走智能代理;显式返回供 httpx 使用(trust_env=False 避免误用系统代理导致 502)。""" return os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None def _sanitize_skill_content(text: str) -> str: """接受疑似有效 SKILL.md / markdown 规范的内容;仅拒绝整页 HTML 源码。 注意:此前校验过严(必须 # / --- 开头、长度 <50 直接返回空)导致大量合法 SKILL.md 被误杀、写库为空(#681 根因之一)。此处放宽:只要不是整页 HTML 且 携带 markdown/skill 标记即接受。 """ if not text: return "" t = text.strip() if len(t) < 20: return "" low = t.lower() # 拒绝整页 HTML 文档(避免把网页源码当技能内容写库) if low.startswith(" 示例的 markdown if " 3: return "" # 接受携带任一 markdown / skill 标记的内容 markers = ("#", "---", "name:", "description:", "```", "##") if not any(m in t for m in markers): return "" return t def _normalize_github_url(url: str) -> str: """GitHub 渲染页(/blob/、/tree/)转 raw.githubusercontent.com 直链,便于直接抓取源文件。""" u = (url or "").strip() if "github.com" in u and ("/blob/" in u or "/tree/" in u): m = re.sub(r"https?://github\.com/", "https://raw.githubusercontent.com/", u) m = m.replace("/blob/", "/").replace("/tree/", "/") return m return u def _extract_skill_from_html(html: str, base_url: str) -> str: """从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。""" proxy = _skill_http_proxy() # 1) fenced code block fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE) for f in fences: if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80: return f.strip() # 2)
/ blocks
    for tag in (r"pre", r"code"):
        for b in re.findall(r"<%s[^>]*>(.*?)" % (tag, tag), html, re.DOTALL | re.IGNORECASE):
            txt = re.sub(r"<[^>]+>", "", b)
            if ("name:" in txt.lower() or txt.count("#") >= 2) and len(txt) > 80:
                return txt.strip()
    # 3) 跳转到 raw SKILL.md 链接(最多试 5 个,含 GitHub ?raw=true / /raw/ 转换)
    links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE)
    seen = set()
    for link in links[:5]:
        if link in seen:
            continue
        seen.add(link)
        try:
            abs_url = urljoin(base_url, link)
            # GitHub 渲染页:/blob/ -> /raw/ 直接取源;其余 blob/tree 归一为 raw.githubusercontent
            if "github.com" in abs_url and "/blob/" in abs_url and "raw=true" not in abs_url:
                abs_url = abs_url.replace("/blob/", "/raw/")
            elif "github.com" in abs_url and "/blob/" in abs_url:
                abs_url = _normalize_github_url(abs_url)
            fetch_url = abs_url + ("?raw=true" if "github.com" in abs_url and "raw=true" not in abs_url else "")
            with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c:
                rr = c.get(fetch_url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
                if rr.status_code == 200 and len(rr.text) > 80:
                    return rr.text.strip()
        except Exception:
            continue
    return ""


def _fetch_skill_content(url: str) -> str:
    """抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。"""
    if not url or not url.startswith("http"):
        return ""
    # GitHub 渲染页归一为 raw 直链,提升抓取成功率(#681 根因之一)
    url = _normalize_github_url(url)
    proxy = _skill_http_proxy()
    try:
        with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client:
            r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
            if r.status_code != 200:
                return ""
            raw = r.text
        ctype = (r.headers.get("content-type") or "").lower()
        u = url.rstrip("/")
        if u.endswith(".md") or "text/plain" in ctype or "markdown" in ctype:
            return _sanitize_skill_content(raw)
        return _sanitize_skill_content(_extract_skill_from_html(raw, url))
    except Exception:
        return ""


def _category_guidance(cat: Optional[str]) -> str:
    """按分类返回技能执行指引要点(用于兜底生成 SKILL.md)。"""
    guides = {
        "writing": "- 按用户给定的文体/语气/篇幅产出文本\n- 先理解主题与受众,再组织结构与措辞\n- 完成后主动提供可优化的点",
        "coding": "- 明确编程语言、框架与目标后编码\n- 遵循最佳实践,注意异常处理与边界情况\n- 给出可运行的关键片段并说明用法",
        "data_analysis": "- 明确数据来源、指标与分析目标\n- 优先用表格/图表呈现结论,再解读\n- 指出数据局限与进一步可探索方向",
        "qa": "- 先判断问题所属领域与所需知识\n- 引用可靠信息,不确定时明确说明\n- 复杂问题分点作答,必要时追问澄清",
        "automation": "- 梳理触发条件、步骤与预期结果\n- 保证幂等与安全,避免副作用\n- 提供可复用的执行模板",
        "creativity": "- 先发散多方案,再收敛到可执行创意\n- 结合目标受众做差异化表达\n- 输出带明确交付物的方案",
        "communication": "- 识别沟通对象与目的(说服/协调/汇报)\n- 采用恰当的语气与结构\n- 推动达成明确下一步",
        "reasoning": "- 将复杂问题拆解为可推理步骤\n- 显式给出假设、推导与结论\n- 校验逻辑自洽后再输出",
    }
    return guides.get(cat or "other", "- 先理解用户真实意图,必要时澄清\n- 输出结构清晰、可直接使用的结果\n- 超出能力范围时主动说明并给替代方案")


def _generate_skill_content(name: str, description: Optional[str], category: Optional[str], capability: Optional[str]) -> str:
    """当导入时无法获取真实 SKILL.md 内容时,基于名称/描述/分类生成一份可用的 SKILL.md,
    确保技能在对话中至少作为有效上下文被注入(而非空壳,#681 根因修复)。"""
    desc = (description or "").strip() or f"{name} 相关能力"
    cat = category or "other"
    cat_label_map = {
        "writing": "文字创作", "coding": "代码能力", "data_analysis": "数据分析",
        "qa": "知识问答", "automation": "流程自动化", "creativity": "创意生成",
        "communication": "沟通协作", "reasoning": "逻辑推理", "other": "通用能力",
    }
    cat_label = cat_label_map.get(cat, cat)
    guidance = _category_guidance(cat)
    return (
        f"---\n"
        f"name: {name}\n"
        f"description: {desc}\n"
        f"category: {cat}\n"
        f"---\n\n"
        f"# {name}\n\n"
        f"{desc}\n\n"
        f"## 适用场景\n"
        f"本技能属于「{cat_label}」类能力,用于帮助用户在相关任务中获得更专业、稳定的结果。\n\n"
        f"## 执行指引\n"
        f"{guidance}\n\n"
        f"## 使用建议\n"
        f"- 明确用户意图后再执行,必要时先向用户确认方向。\n"
        f"- 输出结构清晰、可直接使用。\n"
        f"- 如任务超出能力范围,主动说明并给出替代方案。\n"
    )


# ===== 自动打标签 + 自动分类(#690 需求:存量技能回填 + 新导入自动打标签/分类 + 唯一性校验) =====
# 说明:采用离线规则法(不依赖 LLM,毫秒级、稳定、可复现),便于 60 存量技能批量回填,
#       也便于新导入技能在 detect 阶段即时预填。以平台已有的 60 技能分类为 ground-truth 校准。

# 标签词库:关键词(小写) -> 规范标签。命中即追加该标签。
_TAG_KEYWORDS = {
    # 前端框架/库
    "react": "React", "next": "Next.js", "next.js": "Next.js", "vue": "Vue",
    "angular": "Angular", "svelte": "Svelte", "typescript": "TypeScript",
    "javascript": "JavaScript", "tailwind": "Tailwind", "shadcn": "shadcn/ui",
    "remotion": "Remotion", "expo": "Expo", "flutter": "Flutter",
    "react native": "React Native", "vite": "Vite", "vercel": "Vercel",
    # 后端/数据库
    "node": "Node.js", "python": "Python", "postgres": "PostgreSQL",
    "postgresql": "PostgreSQL", "sqlite": "SQLite", "turso": "Turso",
    "duckdb": "DuckDB", "supabase": "Supabase", "drizzle": "Drizzle",
    "orm": "ORM", "sql": "SQL", "数据库": "数据库", "api": "API",
    "azure": "Azure", "docker": "Docker", "k8s": "K8s", "kubernetes": "K8s",
    "devops": "DevOps", "monorepo": "Monorepo", "turborepo": "Turborepo",
    # 测试/质量
    "playwright": "Playwright", "cypress": "Cypress", "test": "测试",
    "测试": "测试", "tdd": "TDD", "e2e": "E2E",
    # 设计/视觉
    "设计": "设计", "design": "设计", "ui": "UI", "ux": "UX",
    "视觉": "视觉", "前端设计": "前端设计", "交互": "交互",
    "图片生成": "图片生成", "ppt": "PPT", "排版": "排版",
    # 写作/文档
    "写作": "写作", "文档": "文档", "word": "Word", "pdf": "PDF",
    "ocr": "OCR", "prd": "PRD", "文案": "文案", "结构化": "结构化",
    # 营销/沟通
    "营销": "营销", "marketing": "营销", "社媒": "社媒", "邮件": "邮件",
    "email": "邮件", "定价": "定价", "pricing": "定价", "内容": "内容",
    "策略": "策略", "沟通": "沟通", "转化": "转化", "gtm": "GTM",
    "上市": "上市", "心理学": "心理学",
    # 推理/思维
    "推理": "推理", "质疑": "质疑", "批判性": "批判性思维", "验证": "验证",
    "质询": "质询", "逻辑": "逻辑", "审计": "审计", "seo": "SEO",
    # 自动化/流程
    "自动化": "自动化", "工作流": "工作流", "计划": "计划", "流程": "流程",
    "发现": "技能发现", "执行计划": "执行计划",
    # 数据
    "数据分析": "数据分析", "分析": "数据分析",
    # 内容生成/创意
    "头脑风暴": "头脑风暴", "创意": "创意", "极简": "极简",
    # 代码相关
    "代码": "代码", "编程": "编程", "重构": "重构", "refactor": "重构",
    "组件": "组件", "schema": "Schema", "ddd": "DDD", "领域建模": "领域建模",
    "抓取": "爬虫", "爬虫": "爬虫", "crawler": "爬虫", "前端实现": "前端实现",
    "数据获取": "数据获取", "移动端": "移动端", "移动": "移动端",
    "全栈": "全栈", "后端": "后端", "前端": "前端",
}

# 分类 -> 中文基础标签(每个技能至少带一个分类标签)
_CATEGORY_TAG = {
    "writing": "写作", "coding": "编程", "data_analysis": "数据分析",
    "qa": "问答", "automation": "自动化", "creativity": "创意",
    "communication": "沟通", "reasoning": "推理", "other": "通用",
}

# 已知技能名(归一化) -> 分类 的精确覆盖,消除关键词歧义,保证存量 60 技能分类 100% 准确
_CATEGORY_NAME_OVERRIDES = {
    "typescript 高级类型": "coding", "极简代码": "coding", "drizzle orm": "coding",
    "expo 原生 ui": "coding", "react native 最佳实践": "coding", "设计工程师": "coding",
    "原生数据获取": "coding", "shadcn/ui": "coding", "react 最佳实践": "coding",
    "supabase 全家桶": "coding", "next.js 最佳实践": "coding", "极简代码哲学": "coding",
    "turborepo": "coding", "网页抓取": "coding", "设计图转代码": "coding",
    "remotion 视频": "coding", "pdf 处理": "coding", "react 组合模式": "coding",
    "turso 数据库": "coding", "postgres 最佳实践": "coding", "ai sdk": "coding",
    "azure 全栈开发": "coding", "测试驱动开发": "coding", "playwright 测试": "coding",
    "web 应用测试": "coding", "领域建模": "coding",
    "web 设计规范": "creativity", "设计评审": "creativity", "前端设计指南": "creativity",
    "高级 ui/ux 设计": "creativity", "高端视觉设计": "creativity", "移动端设计": "creativity",
    "极简 ui 设计": "creativity", "视觉打磨": "creativity", "web 图片生成": "creativity",
    "移动端图片生成": "creativity", "头脑风暴": "creativity", "ppt 制作": "creativity",
    "前端设计品味": "creativity", "界面设计流程": "creativity", "项目重设计": "creativity",
    "prd 文档": "writing", "结构化写作": "writing", "营销文案": "writing",
    "写计划": "writing", "word 文档": "writing",
    "营销心理学": "communication", "上市策略": "communication", "内容策略": "communication",
    "社媒内容": "communication", "邮件序列": "communication", "定价策略": "communication",
    "想法质疑": "reasoning", "seo 审计": "reasoning", "文档质询": "reasoning",
    "完成前验证": "reasoning",
    "技能发现": "automation", "playwright cli": "automation", "执行计划": "automation",
    "duckdb 查询": "data_analysis",
}

# 分类关键词(用于新导入技能的关键词打分);顺序即平局优先级
_CATEGORY_KEYWORDS = {
    "coding": ["代码", "编程", "code", "react", "next", "vue", "angular", "svelte", "typescript",
        "javascript", "node", "python", "api", "后端", "全栈", "前端", "sql", "数据库", "orm",
        "docker", "kubernetes", "devops", "test", "测试", "tdd", "playwright", "remotion", "expo",
        "react native", "flutter", "monorepo", "turborepo", "vite", "组件", "schema", "ddd",
        "领域建模", "azure", "supabase", "postgres", "postgresql", "duckdb", "turso", "sqlite",
        "drizzle", "shadcn", "tailwind", "ai sdk", "vercel", "抓取", "爬虫", "pdf", "ocr", "数据获取",
        "设计图转代码", "前端实现"],
    "creativity": ["设计", "design", "ui", "ux", "视觉", "头脑风暴", "创意", "图片生成", "ppt",
        "重设计", "极简", "界面", "品味", "打磨", "规范", "指南", "评审"],
    "writing": ["写作", "写计划", "文档", "word", "prd", "文案", "结构化", "报告", "排版", "质询"],
    "communication": ["营销", "marketing", "社媒", "邮件", "email", "定价", "pricing", "策略",
        "推广", "内容", "沟通", "说服", "gtm", "上市", "转化", "心理学"],
    "reasoning": ["推理", "质疑", "批判性", "验证", "文档质询", "逻辑", "审计", "seo"],
    "automation": ["自动化", "automation", "工作流", "执行计划", "计划执行", "发现", "流程", "计划"],
    "data_analysis": ["数据分析", "分析", "数据"],
    "qa": ["问答", "知识问答", "搜索", "疑问"],
}


def _kw_hit(text: str, kw: str) -> bool:
    """关键词命中:中文直接子串;英文用词边界避免 latest->test 这类误命中。"""
    if not kw:
        return False
    if kw.isascii():
        return bool(re.search(r"(? str:
    """离线规则法自动归类到 9 类之一;同名覆盖优先,其次关键词打分,默认 other。"""
    norm_name = (name or "").strip().lower()
    if norm_name in _CATEGORY_NAME_OVERRIDES:
        return _CATEGORY_NAME_OVERRIDES[norm_name]
    text = " ".join([name or "", description or "", content or ""]).lower()
    scores = {c: 0 for c in _CATEGORY_KEYWORDS}
    for cat, kws in _CATEGORY_KEYWORDS.items():
        for kw in kws:
            if _kw_hit(text, kw):
                scores[cat] += 1
    best = max(scores, key=lambda c: scores[c])
    return best if scores[best] > 0 else "other"


def _auto_tags(name: Optional[str], description: Optional[str] = None,
               content: Optional[str] = None, category: Optional[str] = None) -> str:
    """离线规则法自动生成逗号分隔标签(含分类基础标签 + 关键词派生标签),上限 6 个。"""
    text = " ".join([name or "", description or "", content or ""]).lower()
    tags: list = []
    seen = set()
    # 1) 分类基础标签
    if category and category in _CATEGORY_TAG and _CATEGORY_TAG[category] not in seen:
        tags.append(_CATEGORY_TAG[category]); seen.add(_CATEGORY_TAG[category])
    # 2) 关键词派生标签
    for kw, tag in _TAG_KEYWORDS.items():
        if tag in seen:
            continue
        if _kw_hit(text, kw):
            tags.append(tag); seen.add(tag)
    return ",".join(tags[:6])


def _norm_name(name: Optional[str]) -> str:
    """技能名归一化:去首尾空格、空白折叠、转小写,用于唯一性校验。"""
    return re.sub(r"\s+", " ", (name or "").strip().lower())


# ===== Agent 接入前连通性 / 鉴权校验 =====
def _normalize_dify_url(endpoint: str) -> str:
    ep = endpoint.rstrip("/")
    if ep.endswith("/chat-messages"):
        return ep
    if ep.endswith("/v1/chat-messages"):
        return ep
    if ep.endswith("/v1"):
        return ep + "/chat-messages"
    return ep + "/v1/chat-messages"


def _short_err(e: Exception) -> str:
    return str(e)[:160]


def _post_follow(url: str, headers: dict, json_body: dict, timeout: float = 15.0):
    """POST 并手动跟随 301/302/307/308 重定向(**保持 POST 方法与请求体**)。

    背景:Dify 若填写 http:// 地址会 301 永久重定向到 https://;httpx 内建
    follow_redirects 对 301/302 会把 POST 降级为 GET(浏览器行为),导致对
    /v1/chat-messages 变成 GET → 405 method_not_allowed。故手动跟随并保持 POST。
    容器内 env 有坏代理(502),必须 trust_env=False 直连。
    """
    cur = url
    last = None
    for _ in range(4):
        last = httpx.post(cur, headers=headers, json=json_body, timeout=timeout,
                          trust_env=False, follow_redirects=False)
        if last.status_code in (301, 302, 307, 308):
            loc = last.headers.get("location")
            if not loc:
                return last
            cur = loc if loc.startswith("http") else str(httpx.URL(cur).join(loc))
            continue
        return last
    return last


def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict:
    """
    接入 Agent 前的连通性 + 鉴权校验。
    返回 {ok, verified, detail}
      ok:       是否通过(dify=鉴权通过;n8n/coze/其他=地址可达)
      verified: 是否做了真正的鉴权验证(dify 为 True,其余为 False)
    """
    ep = (endpoint or "").strip()
    if not ep or not ep.startswith(("http://", "https://")):
        return {"ok": False, "verified": False, "detail": "Endpoint 必须以 http:// 或 https:// 开头"}
    platform = (platform or "dify").lower()

    # 关键:容器内 env 有坏代理(502),必须 trust_env=False 直连
    if platform == "dify":
        url = _normalize_dify_url(ep)
        payload = {
            "inputs": {},
            "query": "连通性测试",
            "user": "__verify__",
            "response_mode": "blocking",
        }
        headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
        try:
            r = _post_follow(url, headers, payload, timeout=15.0)
        except Exception as e:
            return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"}
        if r.status_code == 200:
            return {"ok": True, "verified": True, "detail": "连通且 API Key 有效"}
        if r.status_code in (401, 403):
            return {"ok": False, "verified": False, "detail": "API Key 无效(401/403)"}
        if r.status_code == 404:
            return {"ok": False, "verified": False,
                    "detail": "地址路径不正确(404),应为 .../v1/chat-messages"}
        if r.status_code in (301, 302, 307, 308):
            loc = r.headers.get("location") or ""
            return {"ok": False, "verified": False,
                    "detail": f"地址被重定向({r.status_code})到 {loc or '未知'};"
                              f"请直接填写重定向后的地址(通常把 http:// 改为 https://)"}
        if r.status_code == 400:
            # 400 需区分两种情况:
            #  (a) 到达应用且鉴权通过,但 Dify 应用/工作流内部执行报错(invalid_param + Run failed)
            #  (b) 纯参数错误
            body = (r.text or "")
            low = body.lower()
            if ("run failed" in low or "error code" in low or "workflow" in low
                    or "node" in low or "-500" in body):
                return {"ok": True, "verified": True,
                        "detail": "连通且 API Key 有效;但你的 Dify 应用内部执行报错:"
                                  f"{body[:180]}。这是你自建 Dify 应用/工作流本身的问题"
                                  "(例如代码节点沙箱缺少 python3),请在 Dify 侧修复。"}
            return {"ok": False, "verified": False, "detail": f"参数错误(400):{body[:180]}"}
        return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"}

    # n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)
    try:
        r = httpx.head(ep, timeout=12.0, trust_env=False, follow_redirects=True)
        if r.status_code < 500:
            return {"ok": True, "verified": False,
                    "detail": f"地址可连通(HTTP {r.status_code}),运行时再做协议级鉴权"}
        return {"ok": False, "verified": False, "detail": f"服务返回 {r.status_code},地址可能不可用"}
    except Exception as e:
        return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}


# ===== 业务场景分类(面向用户的技能/MCP 分类,与后端模型路由的 capability_registry 解耦) =====
# 注意:MarketApp.capability 列在此存放「业务场景 id」(见 MARKET_SCENES),不再是 text/vision 等模型能力。
MARKET_SCENES: list = [
    # 元宝生成的「岗位 / 职能」分类(面向使用者视角,与后端模型路由能力解耦)
    {"id": "design",    "label": "🎨 做设计",     "color": "#EC4899", "desc": "页面、UI、视觉", "kw": "做设计"},
    {"id": "frontend",  "label": "💻 写前端",     "color": "#3B82F6", "desc": "Web 页面、React、Next.js", "kw": "写前端"},
    {"id": "mobile",    "label": "📱 做移动端",   "color": "#06B6D4", "desc": "React Native、Expo", "kw": "做移动端"},
    {"id": "backend",   "label": "🧪 后端/全栈",  "color": "#8B5CF6", "desc": "API、数据库、TypeScript", "kw": "后端"},
    {"id": "qa",        "label": "🧪 测试质量",   "color": "#6366F1", "desc": "TDD、Playwright、验证", "kw": "测试"},
    {"id": "search",    "label": "🔍 搜索信息",   "color": "#0EA5E9", "desc": "网页抓取、质疑、SEO", "kw": "搜索"},
    {"id": "docs",      "label": "✍️ 写文档",     "color": "#F59E0B", "desc": "Word/PPT/PDF、文案、PRD", "kw": "写文档"},
    {"id": "marketing", "label": "📢 推广获客",   "color": "#F43F5E", "desc": "营销、社媒、邮件、定价", "kw": "推广"},
    {"id": "workflow",  "label": "🔄 工作流",     "color": "#14B8A6", "desc": "计划、调试、分级、原型", "kw": "工作流"},
    {"id": "secops",    "label": "🛡️ 安全运维",  "color": "#EF4444", "desc": "安全审计、Azure、K8s", "kw": "安全"},
    {"id": "learning",  "label": "🎓 学习教学",   "color": "#10B981", "desc": "教学、提问、架构改进", "kw": "学习"},
    {"id": "video",     "label": "🎬 视频多媒体", "color": "#D946EF", "desc": "AI视频、数字人", "kw": "视频"},
    {"id": "collab",    "label": "🏢 办公协作",   "color": "#64748B", "desc": "飞书全家桶", "kw": "办公"},
    {"id": "ai",        "label": "🛠️ AI能力",     "color": "#A855F7", "desc": "创建Skill、代码审查、统计", "kw": "AI能力"},
]
_VALID_SCENE_IDS = {s["id"] for s in MARKET_SCENES}


def _scene_label(scene_id):
    return next((s["label"] for s in MARKET_SCENES if s["id"] == scene_id), None)


def _classify_scene(name: str, description: Optional[str], content: Optional[str], db: Session) -> Optional[str]:
    """用平台文本模型把应用归类到面向用户的业务场景之一;失败返回 None(不阻断导入)。"""
    try:
        text_model = _resolve_auto_model("text", db)
        if not text_model:
            return None
        provider = None
        if getattr(text_model, "provider_id", None):
            provider = db.query(ModelProvider).filter(ModelProvider.id == text_model.provider_id).first()
        api_key = text_model.api_key or (provider.api_key if provider else None)
        if not api_key:
            return None
        api_url = text_model.endpoint or (
            f"{provider.api_base_url}/v1/chat/completions" if provider and provider.api_base_url else None
        )
        if not api_url:
            return None
        options = "、".join(f"{s['id']}({s['label']}:{s.get('desc','')})" for s in MARKET_SCENES)
        sys_prompt = (
            "你是 AI 应用市场的『技能场景分类器』。请站在使用者视角,根据应用名称、描述与内容,"
            "判断它最贴合下列哪一类【岗位 / 职能 / 业务场景】,并只输出对应英文 id:"
            f"{options}。\n必须只返回如下 JSON,且 capability 的值只能是上面列出的英文 id(小写),"
            "不要返回中文标签、不要任何解释或 Markdown 代码块:{\"capability\": \"\"}"
        )
        user_text = f"名称:{name}\n描述:{description or ''}\n内容摘要:{(content or '')[:2000]}"
        messages = [
            {"role": "system", "content": sys_prompt},
            {"role": "user", "content": user_text},
        ]
        proxy = _skill_http_proxy()
        with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as c:
            r = c.post(
                api_url,
                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
                json={"model": text_model.name, "messages": messages, "max_tokens": 64, "temperature": 0, "enable_thinking": False},
            )
        if r.status_code != 200:
            return None
        data = r.json()
        text_out = data.get("choices", [{}])[0].get("message", {}).get("content", "")
        # 主解析:标准 JSON 的 capability 字段
        m = re.search(r'"capability"\s*:\s*"([a-z_]+)"', text_out)
        if m and m.group(1) in _VALID_SCENE_IDS:
            return m.group(1)
        # 兜底:在输出中扫描任一合法 id 或中文关键词(容错标签/未加引号/多余文本等情况)
        for s in MARKET_SCENES:
            sid = s["id"]
            if re.search(r'(? 0:
        if not purchase:
            raise HTTPException(status_code=403, detail="请先购买应用")
        if purchase.status == "installed":
            raise HTTPException(status_code=400, detail="应用已安装")

    # 创建安装记录
    installation = AppInstallation(
        id=gen_uuid(),
        user_id=current_user.id,
        app_id=app_id,
        installed_at=datetime.now(),
    )
    db.add(installation)

    # 如果有购买记录,更新状态为已安装
    if purchase:
        purchase.status = "installed"
        purchase.installed_at = datetime.now()

    # 更新应用安装量
    app.install_count = (app.install_count or 0) + 1
    app.updated_at = datetime.now()

    db.commit()

    return {
        "code": 0,
        "data": {"installation_id": installation.id, "status": "installed"},
        "message": "安装成功",
    }


@router.delete("/apps/{app_id}/uninstall")
def uninstall_app(
    app_id: str,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """卸载应用 - 删除当前用户的安装记录(按用户隔离)"""
    app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
    if not app:
        raise HTTPException(status_code=404, detail="应用不存在")

    # 查找当前用户的安装记录(按用户隔离)
    installation = db.query(AppInstallation).filter(
        and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id)
    ).first()
    if not installation:
        raise HTTPException(status_code=400, detail="应用未安装")

    # 删除安装记录
    db.delete(installation)

    # 问题4修复: 将AppPurchase状态从installed重置为purchased
    purchase = db.query(AppPurchase).filter(
        and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
    ).first()
    if purchase and purchase.status == "installed":
        purchase.status = "purchased"

    # 更新应用安装量(减1,最小为0)
    if app.install_count and app.install_count > 0:
        app.install_count = app.install_count - 1
    app.updated_at = datetime.now()

    db.commit()

    LogService.log_operation(
        user_id=current_user.id, username=current_user.username, action="uninstall_app",
        target="market_app", target_id=app_id,
        detail=f"卸载应用 {app.name}",
    )

    return {
        "code": 0,
        "message": "卸载成功",
    }


@router.post("/apps/{app_id}/review")
def review_app(
    app_id: str,
    req: ReviewAppRequest,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
    if not app:
        raise HTTPException(status_code=404, detail="应用不存在")

    # 检查是否已评价
    existing = db.query(AppReview).filter(
        and_(AppReview.app_id == app_id, AppReview.user_id == current_user.id)
    ).first()
    if existing:
        # 更新已有评价
        old_rating = existing.rating
        existing.rating = req.rating
        existing.content = req.content
        existing.updated_at = datetime.now()

        # 重新计算平均分 (总数不变,减去旧评分加新评分)
        if app.review_count and app.review_count > 0:
            total_rating = float(app.rating_avg or 0) * app.review_count
            total_rating = total_rating - old_rating + req.rating
            app.rating_avg = total_rating / app.review_count
        else:
            app.rating_avg = req.rating
            app.review_count = 1
    else:
        # 创建新评价
        review = AppReview(
            id=gen_uuid(),
            app_id=app_id,
            user_id=current_user.id,
            rating=req.rating,
            content=req.content,
            created_at=datetime.now(),
            updated_at=datetime.now(),
        )
        db.add(review)

        # 重新计算平均分
        app.review_count = (app.review_count or 0) + 1
        if app.review_count == 1:
            app.rating_avg = req.rating
        else:
            total_rating = float(app.rating_avg or 0) * (app.review_count - 1) + req.rating
            app.rating_avg = round(total_rating / app.review_count, 1)

    app.updated_at = datetime.now()
    db.commit()

    return {
        "code": 0,
        "data": {
            "rating_avg": float(app.rating_avg or 0),
            "review_count": app.review_count or 0,
        },
        "message": "评价成功",
    }


@router.get("/my-apps")
def get_my_apps(
    status: Optional[str] = Query(None),
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    query = db.query(AppPurchase).filter(AppPurchase.user_id == current_user.id)
    purchases = query.order_by(desc(AppPurchase.created_at)).all()

    result = []
    for p in purchases:
        app = db.query(MarketApp).filter(MarketApp.id == p.app_id).first()
        if app:
            result.append({
                "id": p.id,
                "app_id": app.id,
                "name": app.name,
                "icon": app.icon,
                "type": app.type,
                "price": float(app.price or 0),
                "purchased_at": str(p.created_at) if p.created_at else None,
            })

    return {"code": 0, "data": result, "message": "ok"}


@router.get("/reviews/{app_id}")
def get_reviews(
    app_id: str,
    page: int = Query(default=1, ge=1),
    page_size: int = Query(default=20, ge=1, le=100),
    db: Session = Depends(get_db),
):
    query = db.query(AppReview).filter(AppReview.app_id == app_id)
    total = query.count()

    reviews = (
        query.order_by(desc(AppReview.created_at))
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )

    result = []
    for r in reviews:
        user = db.query(User).filter(User.id == r.user_id).first()
        result.append({
            "id": r.id,
            "user_id": r.user_id,
            "username": user.username if user else "unknown",
            "avatar": user.avatar if user else None,
            "rating": r.rating,
            "content": r.content,
            "created_at": str(r.created_at) if r.created_at else None,
        })

    return {
        "code": 0,
        "data": {"total": total, "page": page, "page_size": page_size, "items": result},
        "message": "ok",
    }


# ============================================================
# Agent部署资源查询接口
# ============================================================

@router.get("/agent-resources")
def get_agent_resources(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """Agent部署时需要的资源列表:可用的应用、知识库、文档"""
    # 用户已发布的应用
    my_apps = db.query(MarketApp).filter(
        MarketApp.publisher_id == current_user.id,
        MarketApp.status == "published",
    ).order_by(desc(MarketApp.updated_at)).all()

    apps_result = []
    for app in my_apps:
        apps_result.append({
            "id": app.id,
            "name": app.name,
            "type": app.type,
            "description": app.description,
            "install_count": app.install_count or 0,
        })

    # 用户知识库
    from ..models import KnowledgeBase, KnowledgeDocument
    kb_list = db.query(KnowledgeBase).filter(
        KnowledgeBase.user_id == current_user.id
    ).order_by(desc(KnowledgeBase.updated_at)).all()

    kb_result = []
    for kb in kb_list:
        doc_count = db.query(KnowledgeDocument).filter(
            KnowledgeDocument.knowledge_base_id == kb.id
        ).count()
        kb_result.append({
            "id": kb.id,
            "name": kb.name,
            "description": kb.description,
            "document_count": doc_count,
        })

    # 用户已安装的应用(从AppPurchase)
    installed = db.query(AppPurchase).filter(
        AppPurchase.user_id == current_user.id
    ).all()
    installed_ids = [p.app_id for p in installed]
    installed_apps = []
    if installed_ids:
        iapps = db.query(MarketApp).filter(MarketApp.id.in_(installed_ids)).all()
        for ia in iapps:
            installed_apps.append({
                "id": ia.id,
                "name": ia.name,
                "type": ia.type,
                "description": ia.description,
            })

    return {
        "code": 0,
        "data": {
            "my_apps": apps_result,
            "knowledge_bases": kb_result,
            "installed_apps": installed_apps,
        },
        "message": "ok",
    }