"""
图谱检索工具模块
包含图谱搜索、故障图谱检索、操作图谱检索等功能
"""
import re
import json
import asyncio
import httpx
from difflib import SequenceMatcher
from typing import Optional, Dict, Any, List
from neo4j import AsyncGraphDatabase
from langchain_core.tools import tool

from config import ATLAS_CONFIG, GRAPH_SEARCH_CONFIG, NEO4J_CONFIG
from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import track_function_calls


GRAPH_SEARCH_ENDPOINT = GRAPH_SEARCH_CONFIG.get("search_endpoint")


@track_function_calls
async def graph_search(
    query: str,
    top_k: Optional[int] = 120,
    use_rerank: Optional[bool] = False,
    fulltext_top_k: Optional[int] = 15,
    rerank_top_k: Optional[int] = 15,
    hops: Optional[int] = 3,
    verbose: Optional[bool] = False
) -> Dict[str, Any]:
    """
    图谱检索工具
    """
    if not query or not query.strip():
        return {"success": False, "error": "查询文本不能为空"}

    query = query.strip()

    payload = {
        "query": query,
        "use_rerank": use_rerank,
        "top_k": top_k,
        "fulltext_top_k": fulltext_top_k,
        "rerank_top_k": rerank_top_k,
        "hops": hops,
        "verbose": verbose
    }

    headers = {
        "accept": "application/json",
        "Content-Type": "application/json"
    }

    try:
        async with httpx.AsyncClient(timeout=180.0, verify=False) as client:
            response = await client.post(GRAPH_SEARCH_ENDPOINT, json=payload, headers=headers)
    except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RequestError) as e:
        return {"success": False, "error": f"请求超时: {str(e)}"}

    if response.status_code != 200:
        return {"success": False, "error": f"HTTP {response.status_code}"}

    result = response.json()
    if result.get("code") != 200:
        msg = result.get("message", "未知错误")
        return {"success": False, "error": msg}

    data = result.get("data", {}) or {}

    try:
        graph_data = []
        results_text = ""

        if isinstance(data, dict):
            raw_nodes = data.get("nodes", [])
            _filter_searchable_labels(raw_nodes)
            # 过滤 nodes 的 properties 字段，仅保留"名称"属性
            for node in raw_nodes:
                if isinstance(node, dict):
                    props = node.get("properties", {}) or node.get("props", {})
                    name_value = props.get("名称", props.get("name", ""))
                    node.pop("properties", None)
                    node.pop("props", None)
                    if name_value:
                        node["properties"] = {"名称": name_value}
            graph_item = {
                "nodes": raw_nodes,
                "links": data.get("links", []),
            }
            graph_item = apply_styles(graph_item)
            graph_data.append(graph_item)
            results_text = data.get("results", "")
        elif isinstance(data, list):
            parts = []
            for item in data:
                if isinstance(item, dict):
                    raw_nodes = item.get("nodes", [])
                    _filter_searchable_labels(raw_nodes)
                    # 过滤 nodes 的 properties 字段，仅保留"名称"属性
                    for node in raw_nodes:
                        if isinstance(node, dict):
                            props = node.get("properties", {}) or node.get("props", {})
                            name_value = props.get("名称", props.get("name", ""))
                            node.pop("properties", None)
                            node.pop("props", None)
                            if name_value:
                                node["properties"] = {"名称": name_value}
                    graph_item = {
                        "nodes": raw_nodes,
                        "links": item.get("links", []),
                    }
                    graph_item = apply_styles(graph_item)
                    graph_data.append(graph_item)
                    if item.get("results"):
                        parts.append(item["results"])
            results_text = "\n".join(parts)

        for graph_item in graph_data:
            links = graph_item.get("links", [])
            for link in links:
                link.pop("properties", None)

        return {
            "success": True,
            "xxxx.graph": graph_data,
            "results": results_text,
        }

    except Exception as e:
        return {"success": False, "error": str(e)}


_MATCH_PUNCT_RE = re.compile(r"[\s\-_·/\\|,，。；;:：()（）\[\]【】{}<>《》\"'“”‘’]+")
_DEVICE_SUFFIX_RE = re.compile(r"(设备|装置|系统|组件|部件|总成|单元|机构|模块|装配|船用|舰用)$")


def _normalize_match_text(text: str) -> str:
    """归一化节点名称，降低空格、标点、常见后缀造成的误差。"""
    if not text:
        return ""
    normalized = _MATCH_PUNCT_RE.sub("", str(text).strip().lower())
    prev = None
    while normalized and normalized != prev:
        prev = normalized
        normalized = _DEVICE_SUFFIX_RE.sub("", normalized)
    return normalized


def _string_similarity(a: str, b: str) -> float:
    """
    简单字符串相似度：综合长度差、包含关系和公共字符比例。
    0~1 之间，越大越相似。
    """
    if not a or not b:
        return 0.0
    a = _normalize_match_text(a)
    b = _normalize_match_text(b)
    if not a or not b:
        return 0.0
    if a == b:
        return 1.0
    if a in b or b in a:
        return 0.95

    set_a, set_b = set(a), set(b)
    inter = len(set_a & set_b)
    union = len(set_a | set_b) or 1
    jaccard = inter / union
    seq_ratio = SequenceMatcher(None, a, b).ratio()

    len_diff = abs(len(a) - len(b))
    len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1))

    return 0.45 * seq_ratio + 0.35 * jaccard + 0.20 * len_penalty


def _coerce_embedding_vector(value: Any) -> Optional[List[float]]:
    """把 Neo4j 里的 embedding 统一成 float 列表；无法解析时返回 None。"""
    if value is None:
        return None
    if isinstance(value, str):
        try:
            value = json.loads(value)
        except json.JSONDecodeError:
            return None
    if not isinstance(value, (list, tuple)) or not value:
        return None
    try:
        return [float(v) for v in value]
    except (TypeError, ValueError):
        return None


def _candidate_display_name(candidate: Dict[str, Any], name_key: str = "display_name") -> str:
    name = candidate.get(name_key) or ""
    if name:
        return str(name)
    props = candidate.get("props") or {}
    return str(props.get("name") or props.get("名称") or props.get("设备名称") or "")


async def _safe_hybrid_match_with_embeddings(
    query_text: str,
    candidates: List[Dict[str, Any]],
    name_key: str = "display_name",
    embedding_key: str = "embedding",
    text_weight: float = 0.45,
    semantic_weight: float = 0.55,
    min_text_score: float = 0.36,
    min_combined_score: float = 0.43,
) -> tuple:
    """
    先用名称相似度兜底，再叠加 embedding。
    embedding 缺失、维度不一致或服务异常时，不让匹配流程失败。
    """
    if not candidates:
        return None, 0.0

    query_embedding = None
    try:
        query_embedding = _coerce_embedding_vector(await OpenaiAPI.get_embeddings_async(query_text))
    except Exception:
        query_embedding = None

    scored_candidates = []
    has_usable_semantic = False
    for idx, candidate in enumerate(candidates):
        name = _candidate_display_name(candidate, name_key=name_key)
        text_score = _string_similarity(query_text, name)

        semantic_score = None
        candidate_embedding = _coerce_embedding_vector(
            (candidate.get("props") or {}).get(embedding_key) or candidate.get(embedding_key)
        )
        if query_embedding and candidate_embedding and len(query_embedding) == len(candidate_embedding):
            semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding)
            has_usable_semantic = True

        if semantic_score is None:
            combined_score = text_score
        else:
            combined_score = text_weight * text_score + semantic_weight * semantic_score
            if text_score >= 0.82:
                combined_score = max(combined_score, text_score)

        scored_candidates.append({
            "candidate": candidate,
            "text_score": text_score,
            "semantic_score": semantic_score,
            "combined_score": combined_score,
            "index": idx,
        })

    scored_candidates.sort(
        key=lambda x: (
            x["combined_score"],
            x["text_score"],
            x["semantic_score"] if x["semantic_score"] is not None else -1.0,
            -x["index"],
        ),
        reverse=True,
    )

    best = scored_candidates[0]
    if best["text_score"] >= 0.82:
        return best["candidate"], best["combined_score"]
    if has_usable_semantic and best["combined_score"] >= min_combined_score:
        return best["candidate"], best["combined_score"]
    if best["text_score"] >= min_text_score:
        return best["candidate"], best["text_score"]
    return None, best["combined_score"]


async def _match_scoped_nodes_with_graph_indexes(
    session,
    query_text: str,
    fulltext_index: str,
    vector_index: str,
    candidate_ids: Optional[List[Any]] = None,
    fallback_candidates: Optional[List[Dict[str, Any]]] = None,
    fulltext_top_k: int = 500,
    vector_top_k: int = 500,
    text_weight: float = 0.20,
    fulltext_weight: float = 0.35,
    vector_weight: float = 0.45,
    min_text_score: float = 0.34,
    min_combined_score: float = 0.42,
) -> tuple:
    """
    使用 Neo4j 全文索引和向量索引在候选范围内匹配节点。
    仅使用索引结果做融合匹配；fallback_candidates 只用于补齐范围查询中的附加字段。
    """
    query_text = str(query_text or "").strip()
    if not query_text:
        return None, 0.0, {"match_source": "empty_query"}

    ids_filter = candidate_ids if candidate_ids else None
    indexed_candidates: Dict[Any, Dict[str, Any]] = {}

    try:
        fulltext_query = query_text.replace("\\", "\\\\").replace('"', '\\"')
        fulltext_cypher = """
        CALL db.index.fulltext.queryNodes($fulltext_index, $query)
        YIELD node, score
        WHERE $candidate_ids IS NULL OR id(node) IN $candidate_ids
        WITH node, score
        ORDER BY score DESC
        LIMIT $top_k
        RETURN
               id(node) AS id,
               labels(node) AS labels,
               properties(node) AS props,
               coalesce(node.名称, node.name, node.设备名称) AS display_name,
               node.embedding AS embedding,
               score AS fulltext_score
        """
        fulltext_records = await session.run(
            fulltext_cypher,
            fulltext_index=fulltext_index,
            query=fulltext_query.strip() or query_text,
            candidate_ids=ids_filter,
            top_k=fulltext_top_k,
        )
        async for record in fulltext_records:
            item = _node_record_to_dict(record)
            item["fulltext_score"] = float(record.get("fulltext_score") or 0.0)
            indexed_candidates[item["id"]] = item
    except Exception as e:
        print(f"[图谱索引匹配] 全文索引 {fulltext_index} 检索失败: {str(e)}")

    query_embedding = None
    try:
        query_embedding = _coerce_embedding_vector(await OpenaiAPI.get_embeddings_async(query_text))
    except Exception:
        query_embedding = None

    if query_embedding:
        try:
            vector_cypher = """
            CALL db.index.vector.queryNodes($vector_index, $top_k, $embedding)
            YIELD node, score
            WHERE $candidate_ids IS NULL OR id(node) IN $candidate_ids
            RETURN
                   id(node) AS id,
                   labels(node) AS labels,
                   properties(node) AS props,
                   coalesce(node.名称, node.name, node.设备名称) AS display_name,
                   node.embedding AS embedding,
                   score AS vector_score
            """
            vector_records = await session.run(
                vector_cypher,
                vector_index=vector_index,
                top_k=vector_top_k,
                embedding=query_embedding,
                candidate_ids=ids_filter,
            )
            async for record in vector_records:
                item = _node_record_to_dict(record)
                item["vector_score"] = float(record.get("vector_score") or 0.0)
                existing = indexed_candidates.get(item["id"], item)
                existing["vector_score"] = item["vector_score"]
                if not existing.get("display_name") and item.get("display_name"):
                    existing["display_name"] = item["display_name"]
                indexed_candidates[item["id"]] = existing
        except Exception as e:
            print(f"[图谱索引匹配] 向量索引 {vector_index} 检索失败: {str(e)}")

    if fallback_candidates and indexed_candidates:
        fallback_by_id = {
            item.get("id"): item
            for item in fallback_candidates
            if item.get("id") is not None
        }
        for candidate_id, candidate in indexed_candidates.items():
            fallback_item = fallback_by_id.get(candidate_id)
            if not fallback_item:
                continue
            if not candidate.get("display_name") and fallback_item.get("display_name"):
                candidate["display_name"] = fallback_item["display_name"]
            if "real_device_id" in fallback_item and "real_device_id" not in candidate:
                candidate["real_device_id"] = fallback_item["real_device_id"]
            if "real_device_name" in fallback_item and "real_device_name" not in candidate:
                candidate["real_device_name"] = fallback_item["real_device_name"]

    if indexed_candidates:
        max_fulltext_score = max(
            [c.get("fulltext_score", 0.0) for c in indexed_candidates.values()] or [0.0]
        )
        scored = []
        for candidate in indexed_candidates.values():
            name = _candidate_display_name(candidate)
            text_score = _string_similarity(query_text, name)
            fulltext_score = candidate.get("fulltext_score", 0.0)
            fulltext_norm = fulltext_score / max_fulltext_score if max_fulltext_score > 0 else 0.0
            vector_score = candidate.get("vector_score", 0.0)
            combined_score = (
                text_weight * text_score +
                fulltext_weight * fulltext_norm +
                vector_weight * vector_score
            )
            if text_score >= 0.82:
                combined_score = max(combined_score, text_score)
            scored.append({
                "candidate": candidate,
                "text_score": text_score,
                "fulltext_score": fulltext_score,
                "fulltext_norm": fulltext_norm,
                "vector_score": vector_score,
                "combined_score": combined_score,
            })

        scored.sort(
            key=lambda x: (
                x["combined_score"],
                x["text_score"],
                x["vector_score"],
                x["fulltext_norm"],
            ),
            reverse=True,
        )
        best = scored[0]
        second = scored[1] if len(scored) > 1 else None
        has_index_signal = best["fulltext_norm"] > 0 or best["vector_score"] > 0
        is_ambiguous = (
            second is not None
            and best["text_score"] < 0.82
            and best["combined_score"] < 0.60
            and (best["combined_score"] - second["combined_score"]) < 0.03
        )
        if (
            best["text_score"] >= 0.82
            or (
                has_index_signal
                and not is_ambiguous
                and best["combined_score"] >= min_combined_score
                and best["text_score"] >= min_text_score
            )
        ):
            return best["candidate"], best["combined_score"], {
                "match_source": "neo4j_index",
                "text_score": best["text_score"],
                "fulltext_score": best["fulltext_score"],
                "vector_score": best["vector_score"],
            }

    best_score = max([c.get("combined_score", 0.0) for c in indexed_candidates.values()] or [0.0])
    return None, best_score, {"match_source": "none"}


def _pick_best_by_name(candidates: List[Dict[str, Any]], target_name: str) -> Optional[Dict[str, Any]]:
    """
    在候选节点中，根据名称相似度选择最优节点。
    约定每个节点为：{id, labels, props, display_name}
    """
    if not candidates or not target_name:
        return None

    best = None
    best_score = 0.0
    for item in candidates:
        name = (item.get("display_name") or "").strip()
        if not name:
            props = item.get("props") or {}
            name = props.get("name") or props.get("名称") or props.get("设备名称") or ""
        score = _string_similarity(target_name, name)
        if score > best_score:
            best_score = score
            best = item

    return best if best_score >= 0.3 else None


def _extract_mt_codes(text: str) -> List[str]:
    """
    从文本中抽取形如 MT007 的维修项目编号（MT + 3位数字）。
    """
    if not text:
        return []
    codes = re.findall(r"\bMT\d{3}\b", str(text))
    seen = set()
    ordered: List[str] = []
    for c in codes:
        if c not in seen:
            seen.add(c)
            ordered.append(c)
    return ordered


def _node_record_to_dict(record: Any) -> Dict[str, Any]:
    """
    将 Cypher 返回的 id/labels/props 结构统一为字典。
    """
    if hasattr(record, "data"):
        data = record.data()
    else:
        data = record or {}
    result = {
        "id": data.get("id"),
        "labels": data.get("labels") or [],
        "props": data.get("props") or {},
        "display_name": data.get("display_name")
        if "display_name" in data
        else None,
    }
    if "embedding" in data:
        result["embedding"] = data.get("embedding")
    if "real_device_id" in data:
        result["real_device_id"] = data.get("real_device_id")
    if "real_device_name" in data:
        result["real_device_name"] = data.get("real_device_name")
    return result


@track_function_calls
async def normalize_device_name_by_ship_graph(
    device_name: str,
    fulltext_index: str = "设备_fulltext",
    vector_index: str = "设备_vector",
    fulltext_top_k: int = 500,
    vector_top_k: int = 500,
    min_text_score: float = 0.34,
    min_combined_score: float = 0.42,
) -> Dict[str, Any]:
    """
    正在标准化设备名称。。。
    标准化失败时返回 success=False，由调用方继续使用原始设备名。
    """
    original_device_name = str(device_name or "").strip()
    if not original_device_name:
        return {"success": False, "device_name": original_device_name, "error": "设备名称不能为空"}

    uri = NEO4J_CONFIG.get("uri")
    user = NEO4J_CONFIG.get("user")
    password = NEO4J_CONFIG.get("password")
    if not uri or not user or not password:
        return {
            "success": False,
            "device_name": original_device_name,
            "error": "Neo4j 配置不完整，请检查 NEO4J_CONFIG",
        }

    try:
        async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver:
            async with driver.session() as session:
                ship_query = """
                MATCH (s:舰艇)
                RETURN DISTINCT
                       id(s) AS id,
                       labels(s) AS labels,
                       properties(s) AS props,
                       coalesce(s.名称, s.name, s.舷号) AS display_name
                LIMIT 2
                """
                ship_records = await session.run(ship_query)
                ship_candidates = [_node_record_to_dict(r) async for r in ship_records]
                if len(ship_candidates) != 1:
                    return {
                        "success": False,
                        "device_name": original_device_name,
                        "error": f"当前图谱舰艇节点数量不是唯一值: {len(ship_candidates)}",
                    }

                ship_node = ship_candidates[0]
                ship_id = ship_node["id"]
                ship_name = _candidate_display_name(ship_node)

                scope_query = """
                MATCH (ship:舰艇)
                WHERE id(ship) = $ship_id
                MATCH (ship)-[:包含*1..6]->(n:设备)
                WITH DISTINCT n, coalesce(n.名称, n.name, n.设备名称) AS nm
                WHERE nm IS NOT NULL
                RETURN collect(id(n)) AS device_ids
                """
                scope_records = await session.run(scope_query, ship_id=ship_id)
                scope_row = await scope_records.single()
                device_ids = scope_row.get("device_ids") if scope_row else []
                if not device_ids:
                    return {
                        "success": False,
                        "device_name": original_device_name,
                        "ship_name": ship_name,
                        "error": "唯一舰艇节点下未找到设备候选节点",
                    }

                async def _fallback_match(reason: str = "") -> Dict[str, Any]:
                    device_query = """
                    MATCH (n:设备)
                    WHERE id(n) IN $device_ids
                    WITH DISTINCT n, coalesce(n.名称, n.name, n.设备名称) AS nm
                    WHERE nm IS NOT NULL
                    RETURN
                           id(n) AS id,
                           labels(n) AS labels,
                           properties(n) AS props,
                           nm AS display_name,
                           n.embedding AS embedding
                    LIMIT 2000
                    """
                    device_records = await session.run(device_query, device_ids=device_ids)
                    device_candidates = [_node_record_to_dict(r) async for r in device_records]
                    if not device_candidates:
                        return {
                            "success": False,
                            "device_name": original_device_name,
                            "ship_name": ship_name,
                            "error": "唯一舰艇节点下未找到设备候选节点",
                        }

                    device_node, device_score = await _safe_hybrid_match_with_embeddings(
                        query_text=original_device_name,
                        candidates=device_candidates,
                        name_key="display_name",
                        embedding_key="embedding",
                        text_weight=0.45,
                        semantic_weight=0.55,
                        min_text_score=min_text_score,
                        min_combined_score=min_combined_score,
                    )
                    if not device_node:
                        return {
                            "success": False,
                            "device_name": original_device_name,
                            "ship_name": ship_name,
                            "best_score": device_score,
                            "error": f"未找到足够匹配的设备节点（最高得分: {device_score:.3f}）",
                        }

                    matched_name = _candidate_display_name(device_node) or original_device_name
                    return {
                        "success": True,
                        "device_name": matched_name,
                        "original_device_name": original_device_name,
                        "ship_name": ship_name,
                        "score": device_score,
                        "matched_node_id": device_node.get("id"),
                        "match_source": "fallback",
                        "fallback_reason": reason,
                    }

                query_embedding = None
                try:
                    query_embedding = _coerce_embedding_vector(
                        await OpenaiAPI.get_embeddings_async(original_device_name)
                    )
                except Exception:
                    query_embedding = None

                indexed_candidates: Dict[Any, Dict[str, Any]] = {}

                try:
                    fulltext_query = original_device_name.replace("\\", "\\\\").replace('"', '\\"')
                    fulltext_query = fulltext_query.strip() or original_device_name
                    fulltext_cypher = """
                    CALL db.index.fulltext.queryNodes($fulltext_index, $query)
                    YIELD node, score
                    WHERE id(node) IN $device_ids
                    WITH node, score
                    ORDER BY score DESC
                    LIMIT $top_k
                    RETURN
                           id(node) AS id,
                           labels(node) AS labels,
                           properties(node) AS props,
                           coalesce(node.名称, node.name, node.设备名称) AS display_name,
                           node.embedding AS embedding,
                           score AS fulltext_score
                    """
                    fulltext_records = await session.run(
                        fulltext_cypher,
                        fulltext_index=fulltext_index,
                        query=fulltext_query,
                        device_ids=device_ids,
                        top_k=fulltext_top_k,
                    )
                    async for record in fulltext_records:
                        item = _node_record_to_dict(record)
                        item["fulltext_score"] = float(record.get("fulltext_score") or 0.0)
                        indexed_candidates[item["id"]] = item
                except Exception as e:
                    print(f"[设备标准化] 全文索引检索失败: {str(e)}")

                if query_embedding:
                    try:
                        vector_cypher = """
                        CALL db.index.vector.queryNodes($vector_index, $top_k, $embedding)
                        YIELD node, score
                        WHERE id(node) IN $device_ids
                        RETURN
                               id(node) AS id,
                               labels(node) AS labels,
                               properties(node) AS props,
                               coalesce(node.名称, node.name, node.设备名称) AS display_name,
                               node.embedding AS embedding,
                               score AS vector_score
                        """
                        vector_records = await session.run(
                            vector_cypher,
                            vector_index=vector_index,
                            top_k=vector_top_k,
                            embedding=query_embedding,
                            device_ids=device_ids,
                        )
                        async for record in vector_records:
                            item = _node_record_to_dict(record)
                            item["vector_score"] = float(record.get("vector_score") or 0.0)
                            existing = indexed_candidates.get(item["id"], item)
                            existing["vector_score"] = item["vector_score"]
                            if not existing.get("display_name") and item.get("display_name"):
                                existing["display_name"] = item["display_name"]
                            indexed_candidates[item["id"]] = existing
                    except Exception as e:
                        print(f"[设备标准化] 向量索引检索失败: {str(e)}")

                if not indexed_candidates:
                    return await _fallback_match("索引未召回设备候选")

                max_fulltext_score = max(
                    [c.get("fulltext_score", 0.0) for c in indexed_candidates.values()] or [0.0]
                )
                scored = []
                for candidate in indexed_candidates.values():
                    name = _candidate_display_name(candidate)
                    text_score = _string_similarity(original_device_name, name)
                    fulltext_score = candidate.get("fulltext_score", 0.0)
                    fulltext_norm = fulltext_score / max_fulltext_score if max_fulltext_score > 0 else 0.0
                    vector_score = candidate.get("vector_score", 0.0)
                    combined_score = (
                        0.20 * text_score +
                        0.35 * fulltext_norm +
                        0.45 * vector_score
                    )
                    if text_score >= 0.82:
                        combined_score = max(combined_score, text_score)
                    scored.append({
                        "candidate": candidate,
                        "text_score": text_score,
                        "fulltext_score": fulltext_score,
                        "fulltext_norm": fulltext_norm,
                        "vector_score": vector_score,
                        "combined_score": combined_score,
                    })

                scored.sort(
                    key=lambda x: (
                        x["combined_score"],
                        x["text_score"],
                        x["vector_score"],
                        x["fulltext_norm"],
                    ),
                    reverse=True,
                )
                best = scored[0]
                best_candidate = best["candidate"]
                if (
                    best["text_score"] < 0.82
                    and best["combined_score"] < min_combined_score
                    and best["text_score"] < min_text_score
                ):
                    return await _fallback_match(
                        f"索引最高得分不足: {best['combined_score']:.3f}"
                    )

                matched_name = _candidate_display_name(best_candidate) or original_device_name
                return {
                    "success": True,
                    "device_name": matched_name,
                    "original_device_name": original_device_name,
                    "ship_name": ship_name,
                    "score": best["combined_score"],
                    "matched_node_id": best_candidate.get("id"),
                    "match_source": "neo4j_index",
                    "text_score": best["text_score"],
                    "fulltext_score": best["fulltext_score"],
                    "vector_score": best["vector_score"],
                }
    except Exception as e:
        return {
            "success": False,
            "device_name": original_device_name,
            "error": f"设备名称标准化失败: {str(e)}",
        }


def _to_graph_display_node(node_dict: Dict[str, Any]) -> Dict[str, Any]:
    """将内部节点格式转为 graph_search 展示用节点：id、name、label。"""
    nid = node_dict.get("id")
    props = node_dict.get("props") or {}
    name = (
        node_dict.get("display_name")
        or props.get("名称")
        or props.get("name")
        or str(nid)
    )
    labels = [l for l in (node_dict.get("labels") or []) if l != "Searchable"]
    label = labels[0] if labels else ""
    return {"id": nid, "name": name, "label": label}


def _filter_props_for_graph(props: Dict[str, Any]) -> Dict[str, Any]:
    exclude_keys = {
        "last_updated",
        "created_at",
        "knowledge_source",
        "fulltext",
        "embedding",
    }
    return {k: v for k, v in (props or {}).items() if k not in exclude_keys}


def _record_to_graph_node(data: Dict[str, Any]) -> Dict[str, Any]:
    props = data.get("props") or {}
    name = data.get("name") or props.get("名称") or props.get("name") or str(data.get("id"))
    return {
        "id": data.get("id"),
        "name": name,
        "labels": data.get("labels") or [],
        "properties": {"名称": name} if name else _filter_props_for_graph(props),
    }


async def _get_unique_or_numbered_ship(session, ship_number: str = "") -> Optional[Dict[str, Any]]:
    if ship_number and str(ship_number).strip():
        ship_key = str(ship_number).strip()
        ship_query = """
        MATCH (s:舰艇)
        WHERE s.舷号 = $ship_key
           OR s.舷号 CONTAINS $ship_key
           OR $ship_key CONTAINS s.舷号
        RETURN id(s) AS id,
               elementId(s) AS eid,
               labels(s) AS labels,
               properties(s) AS props,
               coalesce(s.名称, s.name, s.舷号) AS display_name
        LIMIT 1
        """
        records = await session.run(ship_query, ship_key=ship_key)
        record = await records.single()
        return _node_record_to_dict(record) if record else None

    ship_query = """
    MATCH (s:舰艇)
    RETURN id(s) AS id,
           elementId(s) AS eid,
           labels(s) AS labels,
           properties(s) AS props,
           coalesce(s.名称, s.name, s.舷号) AS display_name
    LIMIT 2
    """
    records = await session.run(ship_query)
    ships = [_node_record_to_dict(r) async for r in records]
    return ships[0] if len(ships) == 1 else None


async def _fetch_node_graph_records(session, node_ids: List[Any]) -> List[Dict[str, Any]]:
    if not node_ids:
        return []
    query = """
    MATCH (n)
    WHERE id(n) IN $node_ids
    RETURN elementId(n) AS id,
           labels(n) AS labels,
           coalesce(n.名称, n.name) AS name,
           properties(n) AS props
    """
    records = await session.run(query, node_ids=node_ids)
    return [_record_to_graph_node(r.data()) async for r in records]


def _build_fault_graph_display(
    ship_node: Optional[Dict[str, Any]],
    device_node: Optional[Dict[str, Any]],
    fault_mode_node: Optional[Dict[str, Any]],
    repair_projects: List[Dict[str, Any]],
) -> tuple:
    """
    构建与 graph_search 一致的图谱展示结构：nodes 与 links。
    """
    nodes: List[Dict[str, Any]] = []
    links: List[Dict[str, Any]] = []
    seen_ids = set()

    def add_node(nd: Optional[Dict[str, Any]]):
        if not nd or nd.get("id") in seen_ids:
            return
        seen_ids.add(nd["id"])
        nodes.append(_to_graph_display_node(nd))

    def add_link(source_id: Any, target_id: Any, rel_type: str = ""):
        links.append({"source": source_id, "target": target_id, "type": rel_type})

    if ship_node:
        add_node(ship_node)
    if device_node:
        add_node(device_node)
        if ship_node:
            add_link(ship_node["id"], device_node["id"], "包含")
    if fault_mode_node:
        add_node(fault_mode_node)
        if device_node:
            add_link(device_node["id"], fault_mode_node["id"], "存在")
    for p in repair_projects or []:
        add_node(p)
        if device_node:
            add_link(device_node["id"], p["id"], "维修时使用")

    return nodes, links


def _build_fault_graph_results_text(
    ship_node: Optional[Dict[str, Any]],
    device_node: Optional[Dict[str, Any]],
    fault_mode_node: Optional[Dict[str, Any]],
    fault_reason: str,
    repair_plan: str,
    mt_codes: List[str],
    repair_projects: List[Dict[str, Any]],
    ship_number: str,
    device_name: str,
    fault_symptom: str,
) -> str:
    """生成与 graph_rag_search 类似的一段话，供展示与下游使用。"""
    ship_name = ""
    if ship_node:
        ship_name = (ship_node.get("display_name") or (ship_node.get("props") or {}).get("名称") or "").strip()
    device_name_d = ""
    if device_node:
        device_name_d = (device_node.get("display_name") or (device_node.get("props") or {}).get("名称") or "").strip()
    fault_name = ""
    if fault_mode_node:
        fault_name = (fault_mode_node.get("display_name") or (fault_mode_node.get("props") or {}).get("名称") or "").strip()

    parts = [
        f"根据舷号「{ship_number}」、设备「{device_name}」、故障现象「{fault_symptom}」进行图谱检索：",
        f"定位到舰艇「{ship_name or '未知'}」、设备节点「{device_name_d or '未知'}」、故障模式「{fault_name or '未知'}」。",
    ]
    if fault_reason:
        parts.append(f"故障原因：{fault_reason}")
    if repair_plan:
        parts.append(f"维修方案摘要：{repair_plan[:500]}{'…' if len(repair_plan) > 500 else ''}")
    if mt_codes:
        parts.append(f"涉及维修项目编号：{'、'.join(mt_codes)}。")
    if repair_projects:
        proj_names = []
        for p in repair_projects:
            nm = (p.get("display_name") or (p.get("props") or {}).get("名称") or (p.get("props") or {}).get("维修项目编号") or "").strip()
            if nm:
                proj_names.append(nm)
        if proj_names:
            parts.append(f"匹配到的维修项目：{'、'.join(proj_names)}。")
    return "\n".join(parts)


@track_function_calls
async def find_system_by_device(
    device_name: str,
    min_hops: int = 2,
    max_hops: int = 8,
    top_k: int = 10,
) -> Dict[str, Any]:
    """根据设备名称反向检索所属系统节点。"""
    if not device_name or not str(device_name).strip():
        return {"success": False, "error": "设备名称不能为空"}

    uri = NEO4J_CONFIG.get("uri")
    user = NEO4J_CONFIG.get("user")
    password = NEO4J_CONFIG.get("password")
    if not uri or not user or not password:
        return {"success": False, "error": "Neo4j 配置不完整，请检查 NEO4J_CONFIG"}

    try:
        async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver:
            async with driver.session() as session:
                ship_node = await _get_unique_or_numbered_ship(session, "")
                device_candidate_ids = None
                if ship_node:
                    scope_query = """
                    MATCH (ship:舰艇)
                    WHERE id(ship) = $ship_id
                    MATCH (ship)-[:包含*1..6]->(n:设备)
                    WITH DISTINCT n, coalesce(n.名称, n.name, n.设备名称) AS nm
                    WHERE nm IS NOT NULL
                    RETURN collect(id(n)) AS device_ids
                    """
                    scope_records = await session.run(scope_query, ship_id=ship_node.get("id"))
                    scope_row = await scope_records.single()
                    device_candidate_ids = scope_row.get("device_ids") if scope_row else None
                    if not device_candidate_ids:
                        return {"success": False, "error": "唯一舰艇节点下未找到设备候选节点"}

                device_node, device_score, _ = await _match_scoped_nodes_with_graph_indexes(
                    session=session,
                    query_text=str(device_name).strip(),
                    fulltext_index="设备_fulltext",
                    vector_index="设备_vector",
                    candidate_ids=device_candidate_ids,
                    fallback_candidates=None,
                    fulltext_top_k=max(top_k, 500),
                    vector_top_k=max(top_k, 500),
                    min_text_score=0.34,
                    min_combined_score=0.42,
                )
                if not device_node:
                    return {"success": False, "error": f"未找到匹配的设备节点（最高得分: {device_score:.3f}）"}

                device_id = device_node.get("id")
                effective_max_hops = max(1, int(max_hops))
                nearest_hops_query = f"""
                MATCH (d:设备)
                WHERE id(d) = $device_id
                MATCH path = (s:系统)-[:包含*1..{effective_max_hops}]->(d)
                RETURN min(length(path)) AS nearest_hops
                """
                nearest_records = await session.run(nearest_hops_query, device_id=device_id)
                nearest_row = await nearest_records.single()
                nearest_hops = nearest_row.get("nearest_hops") if nearest_row else None

                if nearest_hops is None:
                    return {
                        "success": False,
                        "error": "未找到设备对应的系统节点",
                        "device": {
                            "id": device_id,
                            "name": _candidate_display_name(device_node),
                            "labels": device_node.get("labels", []),
                            "properties": _filter_props_for_graph(device_node.get("props", {})),
                        },
                    }

                system_query = f"""
                MATCH (d:设备)
                WHERE id(d) = $device_id
                MATCH path = (s:系统)-[:包含*1..{effective_max_hops}]->(d)
                WHERE length(path) = $nearest_hops
                RETURN DISTINCT id(s) AS id,
                       elementId(s) AS eid,
                       labels(s) AS labels,
                       properties(s) AS props,
                       coalesce(s.名称, s.name) AS display_name,
                       length(path) AS hops
                ORDER BY display_name
                """
                system_records = await session.run(
                    system_query,
                    device_id=device_id,
                    nearest_hops=nearest_hops,
                )
                systems_raw = [_node_record_to_dict(r) async for r in system_records]

                if not systems_raw:
                    return {
                        "success": False,
                        "error": "未找到设备对应的系统节点",
                        "device": {
                            "id": device_id,
                            "name": _candidate_display_name(device_node),
                            "labels": device_node.get("labels", []),
                            "properties": _filter_props_for_graph(device_node.get("props", {})),
                        },
                    }

                systems = []
                for item in systems_raw:
                    systems.append({
                        "id": item.get("id"),
                        "name": _candidate_display_name(item),
                        "labels": item.get("labels", []),
                        "properties": _filter_props_for_graph(item.get("props", {})),
                    })

                path_query = f"""
                MATCH (d:设备)
                WHERE id(d) = $device_id
                MATCH path = (s:系统)-[:包含*1..{effective_max_hops}]->(d)
                WHERE length(path) = $nearest_hops
                WITH path LIMIT 5
                WITH nodes(path) AS ns, relationships(path) AS rs
                RETURN ns, rs
                """
                path_records = await session.run(
                    path_query,
                    device_id=device_id,
                    nearest_hops=nearest_hops,
                )
                nodes_g: List[Dict[str, Any]] = []
                links_g: List[Dict[str, Any]] = []
                seen_nodes = set()
                seen_links = set()
                async for record in path_records:
                    for node in record.get("ns") or []:
                        nid = node.element_id if hasattr(node, "element_id") else node.id
                        if nid in seen_nodes:
                            continue
                        seen_nodes.add(nid)
                        props = dict(node) if hasattr(node, "__iter__") else {}
                        nodes_g.append({
                            "id": nid,
                            "name": props.get("名称") or props.get("name") or str(nid),
                            "labels": list(node.labels) if hasattr(node, "labels") else [],
                            "properties": _filter_props_for_graph(props),
                        })
                    for rel in record.get("rs") or []:
                        rid = rel.element_id if hasattr(rel, "element_id") else rel.id
                        if rid in seen_links:
                            continue
                        seen_links.add(rid)
                        links_g.append({
                            "id": rid,
                            "label": rel.type if hasattr(rel, "type") else str(rel.type),
                            "source": rel.start_node.element_id if hasattr(rel.start_node, "element_id") else rel.start_node.id,
                            "target": rel.end_node.element_id if hasattr(rel.end_node, "element_id") else rel.end_node.id,
                        })

                return {
                    "success": True,
                    "device": {
                        "id": device_id,
                        "name": _candidate_display_name(device_node),
                        "labels": device_node.get("labels", []),
                        "properties": _filter_props_for_graph(device_node.get("props", {})),
                        "match_score": device_score,
                    },
                    "systems": systems,
                    "graph": {"nodes": nodes_g, "links": links_g},
                    "meta": {
                        "system_count": len(systems),
                        "min_hops": min_hops,
                        "max_hops": max_hops,
                    },
                }
    except Exception as e:
        return {"success": False, "error": f"检索失败: {str(e)}"}


@track_function_calls
async def fault_graph_reason_and_projects(
        ship_number: Optional[str] = None,
        device_name: str = "",
        fault_symptom: str = "",
) -> Dict[str, Any]:
    """
    图谱检索工具
    """

    if not device_name or not str(device_name).strip():
        return {"success": False, "error": "设备名称不能为空"}
    if not fault_symptom or not str(fault_symptom).strip():
        return {"success": False, "error": "故障现象不能为空"}

    uri = NEO4J_CONFIG.get("uri")
    user = NEO4J_CONFIG.get("user")
    password = NEO4J_CONFIG.get("password")
    if not uri or not user or not password:
        return {"success": False, "error": "Neo4j 配置不完整，请检查 NEO4J_CONFIG"}

    try:
        async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver:
            async with driver.session() as session:
                device_key = str(device_name).strip()
                ship_id = None
                ship_node = None

                # 有舷号时，先定位舰艇节点
                if ship_number and str(ship_number).strip():
                    ship_key = str(ship_number).strip()
                    ship_query = """
                    MATCH (s:舰艇)
                    WHERE s.舷号 IN [$ship_key]
                    RETURN DISTINCT
                           id(s) AS id,
                           labels(s) AS labels,
                           properties(s) AS props,
                           coalesce(s.名称, s.name, s.舷号) AS display_name
                    LIMIT 10
                    """
                    ship_records = await session.run(ship_query, ship_key=ship_key)
                    ship_candidates = [_node_record_to_dict(r) async for r in ship_records]
                    if ship_candidates:
                        ship_node = ship_candidates[0]
                        ship_id = ship_node["id"]

                if ship_id is not None:
                    device_scope_query = """
                    MATCH (ship:舰艇)
                    WHERE id(ship) = $ship_id
                    MATCH (ship)-[:包含*0..6]->(n:设备)
                    WITH n, coalesce(n.名称, n.name, n.设备名称) AS nm
                    WHERE nm IS NOT NULL
                    RETURN collect(DISTINCT id(n)) AS device_ids
                    """
                    scope_records = await session.run(device_scope_query, ship_id=ship_id)
                    scope_row = await scope_records.single()
                    device_candidate_ids = scope_row.get("device_ids") if scope_row else []
                else:
                    device_candidate_ids = None

                if ship_id is not None and not device_candidate_ids:
                    return {"success": False, "error": "未找到匹配的设备节点"}

                device_node, device_score, device_match_meta = await _match_scoped_nodes_with_graph_indexes(
                    session=session,
                    query_text=device_key,
                    fulltext_index="设备_fulltext",
                    vector_index="设备_vector",
                    candidate_ids=device_candidate_ids if ship_id is not None else None,
                    fallback_candidates=None,
                    fulltext_top_k=500,
                    vector_top_k=500,
                    min_text_score=0.34,
                    min_combined_score=0.42,
                )
                if not device_node:
                    return {"success": False, "error": f"未找到匹配的设备节点（最高得分: {device_score:.3f}）"}
                device_id = device_node["id"]

                fault_mode_scope_query = """
                MATCH (start_e:设备)
                WHERE id(start_e) = $device_id
                MATCH (start_e)-[:包含*0..3]-(real_e:设备)
                MATCH (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式)
                RETURN DISTINCT
                       id(fm) AS id,
                       labels(fm) AS labels,
                       fm.名称 AS display_name,
                       id(real_e) AS real_device_id,
                       real_e.名称 AS real_device_name
                """
                fm_records = await session.run(fault_mode_scope_query, device_id=device_id)
                fault_mode_scope = [_node_record_to_dict(r) async for r in fm_records]
                fault_modes = fault_mode_scope
                if not fault_modes:
                    return {"success": False, "error": "在该设备节点下未找到故障模式节点"}

                fault_mode_ids = [
                    item.get("id") for item in fault_modes if item.get("id") is not None
                ]
                fault_mode_node, fault_score, fault_mode_match_meta = await _match_scoped_nodes_with_graph_indexes(
                    session=session,
                    query_text=fault_symptom,
                    fulltext_index="故障模式_fulltext",
                    vector_index="故障模式_vector",
                    candidate_ids=fault_mode_ids,
                    fallback_candidates=None,
                    fulltext_top_k=500,
                    vector_top_k=500,
                    min_text_score=0.30,
                    min_combined_score=0.40,
                )
                if fault_mode_node:
                    scope_by_id = {
                        item.get("id"): item
                        for item in fault_mode_scope
                        if item.get("id") is not None
                    }
                    scope_item = scope_by_id.get(fault_mode_node.get("id"))
                    if scope_item:
                        if "real_device_id" in scope_item and "real_device_id" not in fault_mode_node:
                            fault_mode_node["real_device_id"] = scope_item["real_device_id"]
                        if "real_device_name" in scope_item and "real_device_name" not in fault_mode_node:
                            fault_mode_node["real_device_name"] = scope_item["real_device_name"]
                if not fault_mode_node:
                    return {"success": False, "error": f"未能在故障模式节点中找到与故障现象足够匹配的节点（最高得分: {fault_score:.3f}）"}

                fault_mode_id = fault_mode_node.get("id")
                if fault_mode_id is None:
                    return {"success": False, "error": "故障模式节点缺少 id，无法继续检索维修项目"}

                real_device_id = fault_mode_node.get("real_device_id")

                fm_props = fault_mode_node.get("props") or {}
                fault_reason = fm_props.get("故障原因") or ""
                repair_plan = fm_props.get("维修方案") or ""
                mt_codes = _extract_mt_codes(repair_plan)

                repair_project_query = """
                MATCH (fm:故障模式)
                WHERE id(fm) = $fault_mode_id
                OPTIONAL MATCH (fm)<-[:修理]-(p:维修项目)
                RETURN DISTINCT
                       id(p) AS id,
                       labels(p) AS labels,
                       properties(p) AS props,
                       p.名称 AS display_name
                """
                rp_records = await session.run(
                    repair_project_query, fault_mode_id=fault_mode_id
                )
                all_projects = [_node_record_to_dict(r) async for r in rp_records]

                repair_projects: List[Dict[str, Any]] = []
                if mt_codes:
                    for proj in all_projects:
                        props = proj.get("props") or {}
                        proj_code = (props.get("维修项目编号") or "").strip()
                        if proj_code and (
                                proj_code in mt_codes or any(c in proj_code for c in mt_codes)
                        ):
                            repair_projects.append(proj)
                        elif not proj_code:
                            values_text = " ".join(
                                [str(v) for v in props.values() if v is not None]
                            )
                            if any(code in values_text for code in mt_codes):
                                repair_projects.append(proj)
                else:
                    repair_projects = all_projects

                repair_ids = [
                    p.get("id") for p in repair_projects if p.get("id") is not None
                ]

                exclude_keys = {
                    "last_updated",
                    "created_at",
                    "knowledge_source",
                    "fulltext",
                    "embedding",
                }

                if ship_id is not None:
                    graph_nodes_query = """
                    MATCH (ship:舰艇)
                    WHERE id(ship) = $ship_id
                    MATCH (real_e:设备)
                    WHERE id(real_e) = $real_device_id
                    MATCH path0 = (ship)-[:包含*0..6]->(real_e)
                    MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式)
                    WHERE id(fm) = $fault_mode_id
                    OPTIONAL MATCH (fm)<-[:修理]-(rp:维修项目)
                    WHERE id(rp) IN $repair_ids
                    WITH path0, path1, collect(DISTINCT rp) AS rps
                    WITH nodes(path0) + nodes(path1) + rps AS ns
                    UNWIND ns AS n
                    WITH DISTINCT n
                    WHERE n IS NOT NULL
                    RETURN elementId(n) AS id,
                           labels(n) AS labels,
                           coalesce(n.名称, n.name) AS name,
                           properties(n) AS props
                    """
                    node_records = await session.run(
                        graph_nodes_query,
                        ship_id=ship_id,
                        real_device_id=real_device_id,
                        fault_mode_id=fault_mode_id,
                        repair_ids=repair_ids or [-1],
                    )
                else:
                    # 无舷号：不查舰艇路径，只查设备→故障模式→维修项目
                    graph_nodes_query = """
                    MATCH (real_e:设备)
                    WHERE id(real_e) = $real_device_id
                    MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式)
                    WHERE id(fm) = $fault_mode_id
                    OPTIONAL MATCH (fm)<-[:修理]-(rp:维修项目)
                    WHERE id(rp) IN $repair_ids
                    WITH path1, collect(DISTINCT rp) AS rps
                    WITH nodes(path1) + rps AS ns
                    UNWIND ns AS n
                    WITH DISTINCT n
                    WHERE n IS NOT NULL
                    RETURN elementId(n) AS id,
                           labels(n) AS labels,
                           coalesce(n.名称, n.name) AS name,
                           properties(n) AS props
                    """
                    node_records = await session.run(
                        graph_nodes_query,
                        real_device_id=real_device_id,
                        fault_mode_id=fault_mode_id,
                        repair_ids=repair_ids or [-1],
                    )

                nodes_g: List[Dict[str, Any]] = []
                async for r in node_records:
                    data_r = r.data()
                    nid = data_r.get("id")
                    if nid is None:
                        continue
                    labels = data_r.get("labels") or []
                    labels = [l for l in labels if l != "Searchable"]
                    name = data_r.get("name") or str(nid)
                    props = data_r.get("props") or {}
                    # 仅保留"名称"属性
                    name_value = props.get("名称", props.get("name", ""))
                    properties = {"名称": name_value} if name_value else {}
                    nodes_g.append(
                        {
                            "id": nid,
                            "name": name,
                            "labels": labels,
                            "properties": properties,
                        }
                    )

                if ship_id is not None:
                    graph_links_query = """
                    MATCH (ship:舰艇)
                    WHERE id(ship) = $ship_id
                    MATCH (real_e:设备)
                    WHERE id(real_e) = $real_device_id
                    MATCH path0 = (ship)-[:包含*0..6]->(real_e)
                    MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式)
                    WHERE id(fm) = $fault_mode_id
                    WITH relationships(path0) + relationships(path1) AS rs
                    UNWIND rs AS r
                    RETURN elementId(r) AS id,
                           type(r) AS label,
                           elementId(startNode(r)) AS source,
                           elementId(endNode(r)) AS target,
                           properties(r) AS props
                    UNION
                    MATCH (fm:故障模式)
                    WHERE id(fm) = $fault_mode_id
                    MATCH (fm)<-[r:修理]-(rp:维修项目)
                    WHERE id(rp) IN $repair_ids
                    RETURN elementId(r) AS id,
                           type(r) AS label,
                           elementId(startNode(r)) AS source,
                           elementId(endNode(r)) AS target,
                           properties(r) AS props
                    """
                    link_records = await session.run(
                        graph_links_query,
                        ship_id=ship_id,
                        real_device_id=real_device_id,
                        fault_mode_id=fault_mode_id,
                        repair_ids=repair_ids or [-1],
                    )
                else:
                    # 无舷号：只查设备→故障模式的边
                    graph_links_query = """
                    MATCH (real_e:设备)
                    WHERE id(real_e) = $real_device_id
                    MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式)
                    WHERE id(fm) = $fault_mode_id
                    WITH relationships(path1) AS rs
                    UNWIND rs AS r
                    RETURN elementId(r) AS id,
                           type(r) AS label,
                           elementId(startNode(r)) AS source,
                           elementId(endNode(r)) AS target,
                           properties(r) AS props
                    UNION
                    MATCH (fm:故障模式)
                    WHERE id(fm) = $fault_mode_id
                    MATCH (fm)<-[r:修理]-(rp:维修项目)
                    WHERE id(rp) IN $repair_ids
                    RETURN elementId(r) AS id,
                           type(r) AS label,
                           elementId(startNode(r)) AS source,
                           elementId(endNode(r)) AS target,
                           properties(r) AS props
                    """
                    link_records = await session.run(
                        graph_links_query,
                        real_device_id=real_device_id,
                        fault_mode_id=fault_mode_id,
                        repair_ids=repair_ids or [-1],
                    )

                links_g: List[Dict[str, Any]] = []
                async for r in link_records:
                    data_r = r.data()
                    src = data_r.get("source")
                    tgt = data_r.get("target")
                    if src is None or tgt is None:
                        continue
                    links_g.append(
                        {
                            "id": data_r.get("id"),
                            "label": data_r.get("label", ""),
                            "source": src,
                            "target": tgt,
                        }
                    )

                node_ids = {n["id"] for n in nodes_g}
                links_g = [
                    l
                    for l in links_g
                    if l.get("source") in node_ids and l.get("target") in node_ids
                ]

                repair_plans_data: List[Dict[str, Any]] = []
                for p in repair_projects:
                    props = p.get("props") or {}
                    filtered = {k: v for k, v in props.items() if k not in exclude_keys}
                    if filtered:
                        repair_plans_data.append(filtered)

                parts = []

                if fault_reason and str(fault_reason).strip():
                    parts.append(str(fault_reason).strip())

                if repair_plans_data:
                    for plan in repair_plans_data:
                        if plan is not None:
                            plan_str = str(plan).strip()
                            if plan_str:
                                parts.append(plan_str)

                if parts:
                    combined_text = "\n".join(parts)
                else:
                    combined_text = "暂无故障信息或修复方案"

                results_obj = combined_text

                results_text = json.dumps(results_obj, ensure_ascii=False)

                graph_item = {"nodes": nodes_g, "links": links_g}
                graph_item = apply_styles(graph_item)
                return {"success": True, "xxxx.graph": [graph_item], "results": results_text}

    except Exception as e:
        return {"success": False, "error": f"Neo4j 查询失败: {str(e)}"}


def add_node_style(node):
    node_type = node.get("labels", [])
    node_type = [l for l in node_type if l != "Searchable"]
    if node_type and len(node_type) > 0:
        node_type = node_type[0]
    else:
        node_type = ""
    
    if node_type == "舰艇":
        node["color"] = "#F89A64"
        node["size"] = 86
    elif node_type == "系统":
        node["color"] = "#C25C21"
        node["size"] = 86
    elif node_type == "子系统":
        node["color"] = "#7D2D00"
        node["size"] = 86
    elif node_type == "设备":
        node["color"] = "#540802"
        node["size"] = 86
    elif node_type in ["功能", "环境条件", "接口", "安全警告", "调试", "器材保障", "图册", "设计单位", "维修项目", "操作项目"]:
        node["color"] = "#2261DF"
        node["size"] = 52
    else:
        node["color"] = "#68C3FF"
        node["size"] = 53
    
    return node


def add_link_style(link, nodes_dict):
    source_id = link.get("source")
    target_id = link.get("target")
    
    source_type = ""
    target_type = ""
    
    if source_id and source_id in nodes_dict:
        source_labels = [l for l in nodes_dict[source_id].get("labels", []) if l != "Searchable"]
        if source_labels and len(source_labels) > 0:
            source_type = source_labels[0]
    
    if target_id and target_id in nodes_dict:
        target_labels = [l for l in nodes_dict[target_id].get("labels", []) if l != "Searchable"]
        if target_labels and len(target_labels) > 0:
            target_type = target_labels[0]
    
    hierarchy_types = ["舰艇", "系统", "子系统", "设备"]
    if source_type in hierarchy_types and target_type in hierarchy_types:
        link["color"] = "#F59E0B"
    else:
        link["color"] = "#FFFFFF"
    
    return link


def _filter_searchable_labels(nodes: List[Dict[str, Any]]):
    for node in nodes:
        if isinstance(node, dict) and "labels" in node:
            node["labels"] = [l for l in node["labels"] if l != "Searchable"]


def apply_styles(graph_data):
    nodes = graph_data.get("nodes", [])
    links = graph_data.get("links", [])
    
    nodes_dict = {n.get("id"): n for n in nodes if n.get("id")}
    
    for node in nodes:
        add_node_style(node)
    
    for link in links:
        add_link_style(link, nodes_dict)
    
    return graph_data


@tool
async def fault_graph_rag_search(
    ship_number: Optional[str] = None,
    device_name: str = "",
    fault_symptom: str = "",
) -> str:
    """
    故障诊断图谱检索工具（供工作流调用）：仅返回一段话摘要，与 graph_rag_search 一致。
    根据设备名称、故障现象在图谱中定位设备→故障模式→维修项目，并生成文字摘要。舷号为可选参数，提供时可缩小搜索范围。
    工作流中用于与 RAG 结果一起作为上下文，不返回节点/关系（展示用 fault_graph_reason_and_projects）。
    """
    result = await fault_graph_reason_and_projects(
        ship_number=ship_number,
        device_name=device_name,
        fault_symptom=fault_symptom,
    )
    if not result.get("success", False):
        return result.get("error", "检索失败")

    
    return result.get("results", "")


@track_function_calls
async def operation_graph_search_tool(
        ship_number: str,
        device_name: str,
        operation_item: str,
) -> Dict[str, Any]:
    """
    图谱检索工具
    """
    if not device_name or not str(device_name).strip():
        return {"success": False, "error": "设备名称不能为空"}
    if not operation_item or not str(operation_item).strip():
        return {"success": False, "error": "操作项目不能为空"}

    uri = NEO4J_CONFIG.get("uri")
    user = NEO4J_CONFIG.get("user")
    password = NEO4J_CONFIG.get("password")
    if not uri or not user or not password:
        return {"success": False, "error": "Neo4j 配置不完整，请检查 NEO4J_CONFIG"}

    try:
        async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver:
            async with driver.session() as session:
                # 操作图谱不再依赖舷号；按当前图谱唯一舰艇节点限定范围。
                ship_node = await _get_unique_or_numbered_ship(session, "")
                if not ship_node:
                    return {"success": False, "error": "未找到唯一舰艇节点"}
                ship_id = ship_node.get("id")

                scope_query = """
                MATCH (ship:舰艇)
                WHERE id(ship) = $ship_id
                MATCH (ship)-[:包含*0..6]->(d:设备)
                WITH DISTINCT d, coalesce(d.名称, d.name, d.设备名称) AS nm
                WHERE nm IS NOT NULL
                RETURN collect(id(d)) AS device_ids
                """
                scope_records = await session.run(scope_query, ship_id=ship_id)
                scope_row = await scope_records.single()
                device_ids = scope_row.get("device_ids") if scope_row else []
                if not device_ids:
                    return {"success": False, "error": "该舰艇范围内未找到设备节点"}

                device_node, device_score, _ = await _match_scoped_nodes_with_graph_indexes(
                    session=session,
                    query_text=str(device_name).strip(),
                    fulltext_index="设备_fulltext",
                    vector_index="设备_vector",
                    candidate_ids=device_ids,
                    fallback_candidates=None,
                    fulltext_top_k=500,
                    vector_top_k=500,
                    min_text_score=0.34,
                    min_combined_score=0.42,
                )
                if not device_node:
                    return {"success": False, "error": f"在该舰艇范围内未找到匹配的设备节点（最高得分: {device_score:.3f}）"}

                device_id = device_node.get("id")
                op_scope_query = """
                MATCH (start_d:设备)
                WHERE id(start_d) = $device_id
                MATCH (start_d)-[:包含*0..6]-(real_d:设备)
                MATCH (real_d)-[:支持操作]-(op_use:操作使用)
                      -[:使用]-(op_proc:操作程序)
                      -[:操作时使用]-(op_item:操作项目)
                RETURN DISTINCT
                       id(op_item) AS id,
                       elementId(op_item) AS eid,
                       labels(op_item) AS labels,
                       properties(op_item) AS props,
                       coalesce(op_item.名称, op_item.name) AS display_name,
                       id(real_d) AS real_device_id
                """
                op_records = await session.run(op_scope_query, device_id=device_id)
                op_scope = [_node_record_to_dict(r) async for r in op_records]
                op_ids = [item.get("id") for item in op_scope if item.get("id") is not None]
                if not op_ids:
                    return {"success": False, "error": "该设备下未找到操作项目节点"}

                op_node, op_score, _ = await _match_scoped_nodes_with_graph_indexes(
                    session=session,
                    query_text=str(operation_item).strip(),
                    fulltext_index="操作项目_fulltext",
                    vector_index="操作项目_vector",
                    candidate_ids=op_ids,
                    fallback_candidates=op_scope,
                    fulltext_top_k=500,
                    vector_top_k=500,
                    min_text_score=0.30,
                    min_combined_score=0.40,
                )
                if not op_node:
                    return {"success": False, "error": f"未找到匹配的操作项目节点（最高得分: {op_score:.3f}）"}

                op_item_id = op_node.get("id")
                real_device_id = op_node.get("real_device_id") or device_id

                graph_nodes_query = """
                MATCH (ship:舰艇)
                WHERE id(ship) = $ship_id
                MATCH (real_d:设备)
                WHERE id(real_d) = $real_device_id
                MATCH (op_item:操作项目)
                WHERE id(op_item) = $op_item_id
                MATCH path1 = (ship)-[:包含*0..6]->(real_d)
                MATCH path2 = (real_d)-[:支持操作]->(op_use:操作使用)
                      -[:使用]->(op_proc:操作程序)
                      -[:操作时使用]->(op_item)
                WITH nodes(path1) + nodes(path2) AS ns
                UNWIND ns AS n
                WITH DISTINCT n
                RETURN elementId(n) AS id,
                       labels(n) AS labels,
                       coalesce(n.名称, n.name) AS name,
                       properties(n) AS props
                """
                node_records = await session.run(
                    graph_nodes_query,
                    ship_id=ship_id,
                    real_device_id=real_device_id,
                    op_item_id=op_item_id,
                )
                nodes_g = [_record_to_graph_node(r.data()) async for r in node_records]

                graph_links_query = """
                MATCH (ship:舰艇)
                WHERE id(ship) = $ship_id
                MATCH (real_d:设备)
                WHERE id(real_d) = $real_device_id
                MATCH (op_item:操作项目)
                WHERE id(op_item) = $op_item_id
                MATCH path1 = (ship)-[:包含*0..6]->(real_d)
                MATCH path2 = (real_d)-[:支持操作]->(op_use:操作使用)
                      -[:使用]->(op_proc:操作程序)
                      -[:操作时使用]->(op_item)
                WITH relationships(path1) + relationships(path2) AS rs
                UNWIND rs AS r
                WITH DISTINCT r
                RETURN elementId(r) AS id,
                       type(r) AS label,
                       elementId(startNode(r)) AS source,
                       elementId(endNode(r)) AS target
                """
                link_records = await session.run(
                    graph_links_query,
                    ship_id=ship_id,
                    real_device_id=real_device_id,
                    op_item_id=op_item_id,
                )
                links_g = []
                node_ids = {n.get("id") for n in nodes_g}
                async for r in link_records:
                    data = r.data()
                    if data.get("source") in node_ids and data.get("target") in node_ids:
                        links_g.append({
                            "id": data.get("id"),
                            "label": data.get("label", ""),
                            "source": data.get("source"),
                            "target": data.get("target"),
                        })

                op_props = _filter_props_for_graph(op_node.get("props") or {})
                results_text = json.dumps(op_props, ensure_ascii=False) if op_props else ""
                graph_item = apply_styles({"nodes": nodes_g, "links": links_g})
                return {"success": True, "xxxx.graph": [graph_item], "results": results_text}
    except Exception as e:
        return {"success": False, "error": f"Neo4j 查询失败: {str(e)}"}


@tool
async def operation_graph_rag_search(
        ship_number: str,
        device_name: str,
        operation_item: str,
) -> str:
    """
    操作图谱检索工具（供工作流调用）：仅返回一段话摘要，与 graph_rag_search 一致。
    根据舷号、设备名称、操作项目在图谱中定位舰艇→设备→操作项目，并生成文字摘要。
    工作流中用于与 RAG 结果一起作为上下文，不返回节点/关系（展示用 operation_graph_search_tool）。
    """
    result = await operation_graph_search_tool(
        ship_number=ship_number,
        device_name=device_name,
        operation_item=operation_item,
    )
    if not result.get("success", False):
        return result.get("error", "检索失败")
    return result.get("results", "")


@tool
async def graph_rag_search(query: str, entity_type: Optional[str] = None, top_k: int = 120) -> str:
    """
    GraphRAG检索工具（异步版本）
    在知识图谱中检索与query相关的实体和关系

    Args:
        query: 检索查询
        entity_type: 实体类型过滤（如：设备、故障、维修项目）- 注意：此参数在当前实现中暂未使用
        top_k: 返回结果数量（保留参数以兼容接口）

    Returns:
        图谱检索结果文本字符串（旧格式，保持兼容）
    """
    print(f"graph_rag_query+++++++++++{query}")
    result = await graph_search(query)

    if not result.get("success", False):
        return ""

    results_text = result.get("results", "")
    if not results_text:
        return ""
    if len(results_text) > 1000:
        results_text = results_text[:1000] + "..."
    return results_text



async def atlas_retrieval(
    node_names: List[str],
    top_k: int = 10,
    timeout: int = 30
) -> Dict[str, Any]:
    """
    图册检索工具：从图谱中找到节点并关联图册PDF

    Args:
        node_names: 节点名称列表，如 ["主发动机", "压缩机"]
        top_k: 返回的结果数量
        timeout: 请求超时时间（秒）

    Returns:
        包含success和data字段的字典
        data格式示例：
        {
            "船舶主发动机": {
                "船舶主发动机的原理图": [
                    "http://192.168.0.46:59085/files/163-06A0015-B01001_船舶主发动机原理图.pdf"
                ]
            }
        }
    """
    if not node_names:
        return {
            "success": False,
            "error": "node_names不能为空",
            "data": {}
        }

    uri = NEO4J_CONFIG.get("uri")
    user = NEO4J_CONFIG.get("user")
    password = NEO4J_CONFIG.get("password")
    if not uri or not user or not password:
        return {"success": False, "error": "Neo4j 配置不完整，请检查 NEO4J_CONFIG", "data": {}}

    try:
        async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver:
            async with driver.session() as session:
                label_indexes = {
                    "设备": ("设备_fulltext", "设备_vector"),
                    "系统": ("系统_fulltext", "系统_vector"),
                    "子系统": ("子系统_fulltext", "子系统_vector"),
                }
                matched_nodes: List[Dict[str, Any]] = []
                seen_node_ids = set()

                for node_name in node_names:
                    query_name = str(node_name or "").strip()
                    if not query_name:
                        continue
                    label_matches = []
                    for label, (fulltext_index, vector_index) in label_indexes.items():
                        node, score, meta = await _match_scoped_nodes_with_graph_indexes(
                            session=session,
                            query_text=query_name,
                            fulltext_index=fulltext_index,
                            vector_index=vector_index,
                            candidate_ids=None,
                            fallback_candidates=None,
                            fulltext_top_k=max(top_k, 500),
                            vector_top_k=max(top_k, 500),
                            min_text_score=0.30,
                            min_combined_score=0.38,
                        )
                        if node:
                            label_matches.append((node, score, meta, label))

                    if not label_matches:
                        continue
                    label_matches.sort(key=lambda x: x[1], reverse=True)
                    best_node, best_score, best_meta, best_label = label_matches[0]
                    node_id = best_node.get("id")
                    if node_id in seen_node_ids:
                        continue
                    seen_node_ids.add(node_id)
                    matched_nodes.append({
                        "id": node_id,
                        "name": _candidate_display_name(best_node),
                        "labels": best_node.get("labels") or [best_label],
                        "props": best_node.get("props", {}),
                        "score": best_score,
                    })

                if not matched_nodes:
                    return {"success": False, "error": "未找到匹配的节点", "data": {}}

                source_ids = [n["id"] for n in matched_nodes if n.get("id") is not None]
                atlas_query = """
                MATCH (source)
                WHERE id(source) IN $source_ids
                MATCH (source)-[*1..1]-(atlas:图册)
                RETURN id(source) AS source_id,
                       coalesce(source.名称, source.name) AS source_name,
                       id(atlas) AS atlas_id,
                       coalesce(atlas.名称, atlas.name) AS atlas_name,
                       properties(atlas) AS atlas_props
                """
                records = await session.run(atlas_query, source_ids=source_ids)
                result_data: Dict[str, Dict[str, List[str]]] = {}
                async for record in records:
                    source_name = record.get("source_name")
                    atlas_name = record.get("atlas_name")
                    atlas_props = record.get("atlas_props") or {}

                    pdf_url = None
                    for key, value in atlas_props.items():
                        key_s = str(key)
                        if "图" in key_s or "pdf" in key_s.lower() or "url" in key_s.lower() or "文件" in key_s:
                            if isinstance(value, str) and value.startswith(("http", "/")):
                                pdf_url = value
                                break

                    if not source_name or not atlas_name:
                        continue
                    result_data.setdefault(source_name, {}).setdefault(atlas_name, [])
                    if pdf_url and pdf_url not in result_data[source_name][atlas_name]:
                        result_data[source_name][atlas_name].append(pdf_url)

                if not result_data:
                    return {
                        "success": False,
                        "error": "未找到关联的图册节点",
                        "data": {},
                        "matched_nodes": matched_nodes,
                    }

                return {"success": True, "data": result_data, "error": None}

    except Exception as e:
        return {
            "success": False,
            "error": str(e),
            "data": {}
        }


def format_atlas_results(atlas_data: Dict[str, Any]) -> str:
    """
    格式化图册检索结果为文本，便于在提示词中使用

    Args:
        atlas_data: atlas_retrieval返回的data字段

    Returns:
        格式化后的文本
    """
    if not atlas_data:
        return ""

    parts = []
    for node_name, node_data in atlas_data.items():
        if isinstance(node_data, dict):
            for title, urls in node_data.items():
                if isinstance(urls, list) and urls:
                    parts.append(f"\n【{node_name} - {title}】")
                    for url in urls:
                        # 确保URL后面有明确的换行，避免和其他文字连在一起
                        parts.append(f"- {url}\n")

    return "\n".join(parts) if parts else ""


async def extract_entity_names_from_history(
    history_messages: List[Dict[str, Any]],
    device_name: str = "",
    operation_item: str = "",
    fault: str = "",
    last_generated_scheme: str = ""
) -> List[str]:
    """
    从历史记录中抽取部件/设备/零部件名称

    Args:
        history_messages: 历史对话记录
        device_name: 设备名称
        operation_item: 操作项目名称
        fault: 故障现象
        last_generated_scheme: 最后一次生成的方案（用于重点抽取）

    Returns:
        抽取的实体名称列表
    """
    entity_names = []

    # 添加明确提供的名称
    if device_name:
        entity_names.append(device_name)
    if operation_item:
        entity_names.append(operation_item)
    if fault:
        entity_names.append(fault)

    # 去重
    entity_names = list(dict.fromkeys(entity_names))
    
    # 调用大模型进一步抽取和补充
    if entity_names or last_generated_scheme:
        try:
            extraction_prompt = f"""从以下信息中抽取尽可能多的设备、部件、零部件、机构、系统名称（用于图册检索）。
注意：
- 请尽可能多抽取，宁多勿少，准不准没关系
- 包括所有提及的机械部件、电子设备、机构系统等
- 即使是重复提到的也可以保留，我们会去重
- 重点从【生成的方案】中抽取，这里信息最全面

【示例 - 输入】
船舶主发动机（舷号：101）故障分析与维修方案
1. 故障概况
设备名称：船舶主发动机
舷号：101
故障现象：主机运行中出现异响
2. 故障原因分析
主轴承或连杆大端轴承磨损：轴承间隙过大或磨损严重会导致金属撞击声。
曲轴主轴颈跳动超差：曲轴旋转不平衡或跳动量超出标准。
飞轮连接螺栓松动：飞轮与曲轴连接处的螺栓松动。
活塞头烧蚀或活塞环断裂：活塞环断裂或活塞头部损坏可能导致敲缸声。
排气阀密封面磨损或阀座烧蚀：气门间隙异常或密封失效。
涡轮增压器轴承损坏：增压器内部轴承损坏。
3. 维修方案
执行项目：MT003 检测曲轴组件（含飞轮紧固状态检查）。
执行项目：MTO03 检测曲轴主轴颈跳动量。

【示例 - 输出】
{{
    "entity_names": ["船舶主发动机", "主轴承", "连杆大端轴承", "曲轴", "飞轮", "飞轮连接螺栓", "活塞头", "活塞环", "排气阀", "排气阀座", "涡轮增压器", "涡轮增压器轴承", "曲轴组件"]
}}

【设备/操作】
- 设备名: {device_name or '无'}
- 操作项: {operation_item or '无'}
- 故障: {fault or '无'}

【生成的方案】
{last_generated_scheme or '无'}

请输出JSON格式，格式如下：
{{
    "entity_names": ["名称1", "名称2", "名称3", "..."]
}}

仅输出JSON，不要其他内容。"""

            response = await OpenaiAPI.open_api_chat_without_thinking(
                query=extraction_prompt,
                json_output=True,
                system_prompt="你是一个专业的实体抽取助手，负责从文本中抽取尽可能多的设备、部件、零部件、机构、系统名称。请尽量多抽取，宁多勿少。重点从生成的维修方案中抽取。"
            )

            parsed = _safe_json_extract(response)
            if isinstance(parsed, dict):
                extracted = parsed.get("entity_names", [])
                if extracted:
                    entity_names.extend(extracted)
                    entity_names = list(dict.fromkeys(entity_names))
        except Exception as e:
            print(f"[图册检索] 实体抽取失败: {str(e)}")

    # 过滤空值和太短的名称
    entity_names = [name.strip() for name in entity_names if name and len(name.strip()) >= 2]
    return entity_names[:30]  # 最多30个，尽量多找图册


def _safe_json_extract(text: str) -> Any:
    """安全地从文本中提取JSON"""
    try:
        # 尝试直接解析
        return json.loads(text)
    except json.JSONDecodeError:
        # 尝试用正则提取
        match = re.search(r'\{[\s\S]*\}', text)
        if match:
            try:
                return json.loads(match.group(0))
            except json.JSONDecodeError:
                pass
        return None
