from __future__ import annotations

import json
import math
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
import sys
from typing import Any, Iterable

from fileatlas.schema import ProjectMap, SymbolDef
from fileatlas.llm import extract_json_content, extract_json_content_ollama, load_llm_from_env
from fileatlas.utils import estimate_tokens, read_text_file
from moduleatlas.tools.project_map_loader import ProjectIndex, build_index

from . import DISPLAY_NAME, INTERNAL_NAME

try:
    from tqdm import tqdm  # type: ignore
except Exception:  # pragma: no cover
    tqdm = None  # type: ignore[assignment]


@dataclass
class LocateAgentConfig:
    query: str
    project_map_path: str = "project_map.json"
    architecture_json_path: str = "architecture.json"
    backbone_md_path: str = "backbone_from_report.md"
    top_files: int = 8
    top_symbols: int = 6
    min_score: float = 0.0
    out_format: str = "markdown"  # "markdown" | "json"
    # Recall stage
    # - heuristic: token-based recall only
    # - llm: LLM-based recall (module->file selection), best-effort fallback if env not configured
    # - hybrid: try llm recall, then fill remaining with heuristic
    recall_mode: str = "hybrid"
    # LLM refinement (best-effort; will fall back if env not configured)
    llm_enabled: bool = True
    llm_concurrency: int = 2
    llm_max_files: int = 6
    llm_max_symbols_per_file: int = 12
    llm_max_excerpt_tokens: int = 2200
    # LLM recall knobs
    llm_recall_max_modules: int = 8
    llm_recall_max_files: int = 12
    llm_recall_file_digest_limit: int = 220
    # If True and recall_mode=="llm", do not fall back to heuristic on LLM failure.
    llm_recall_strict: bool = True
    # Progress reporting
    show_progress: bool = True
    progress_to_stderr: bool = True


def _norm_path(p: str) -> str:
    p = (p or "").replace("\\", "/")
    while p.startswith("./"):
        p = p[2:]
    return p.strip("/")


def _safe_list(x: Any) -> list[Any]:
    return x if isinstance(x, list) else []


def _safe_dict(x: Any) -> dict[str, Any]:
    return x if isinstance(x, dict) else {}


def _p(cfg: LocateAgentConfig, msg: str) -> None:
    """
    Print progress messages (stderr by default) without polluting final output.
    """
    if not bool(getattr(cfg, "show_progress", True)):
        return
    stream = sys.stderr if bool(getattr(cfg, "progress_to_stderr", True)) else sys.stdout
    print(f"[{DISPLAY_NAME}] {msg}", file=stream)


def _wrap_iter(cfg: LocateAgentConfig, it: Iterable[Any], *, total: int | None, desc: str) -> Iterable[Any]:
    """
    Best-effort progress iterator wrapper:
    - Use tqdm if available
    - Otherwise return original iterator (paired with _p stage messages)
    """
    if not bool(getattr(cfg, "show_progress", True)):
        return it
    if tqdm is None:
        return it
    try:
        return tqdm(it, total=total, desc=f"{DISPLAY_NAME}: {desc}", unit="item")  # type: ignore[return-value]
    except Exception:
        return it


def _read_json(path: str) -> dict[str, Any] | None:
    try:
        with open(path, "r", encoding="utf-8") as f:
            obj = json.load(f)
        return obj if isinstance(obj, dict) else None
    except Exception:
        return None


def _iter_cjk_spans(s: str) -> Iterable[str]:
    """
    Yield contiguous CJK (Han) spans as strings.
    """
    buf: list[str] = []

    def is_han(ch: str) -> bool:
        o = ord(ch)
        # Basic CJK Unified Ideographs + common extensions (rough, but OK)
        return (
            0x4E00 <= o <= 0x9FFF
            or 0x3400 <= o <= 0x4DBF
            or 0x20000 <= o <= 0x2A6DF
            or 0x2A700 <= o <= 0x2B73F
            or 0x2B740 <= o <= 0x2B81F
            or 0x2B820 <= o <= 0x2CEAF
        )

    for ch in s or "":
        if is_han(ch):
            buf.append(ch)
        else:
            if buf:
                yield "".join(buf)
                buf = []
    if buf:
        yield "".join(buf)


def _split_ident_tokens(s: str) -> list[str]:
    """
    Tokenize identifiers/english-ish words.
    - Keep dotted/underscored tokens
    - Also add parts split by . and _
    - CamelCase split (best-effort)
    """
    import re

    s = s or ""
    raw = re.findall(r"[A-Za-z_][A-Za-z0-9_\.]*", s)
    out: list[str] = []

    def camel_split(tok: str) -> list[str]:
        # e.g. "ChatCompletionScheduler" -> ["Chat", "Completion", "Scheduler"]
        parts = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", tok)
        return [p for p in parts if p]

    for tok in raw:
        t = tok.strip()
        if not t:
            continue
        out.append(t)
        for p in re.split(r"[._]", t):
            if p and p != t:
                out.append(p)
                for c in camel_split(p):
                    if c and c != p:
                        out.append(c)
    return out


def _cjk_ngrams(span: str, *, n_min: int = 2, n_max: int = 3) -> list[str]:
    span = (span or "").strip()
    if not span:
        return []
    out: list[str] = []
    L = len(span)
    for n in range(max(1, int(n_min)), max(1, int(n_max)) + 1):
        if L < n:
            continue
        for i in range(0, L - n + 1):
            out.append(span[i : i + n])
    # Also include full span if short (helps phrases like "下发")
    if 1 <= L <= 6:
        out.append(span)
    return out


def tokenize(text: str) -> list[str]:
    """
    Mixed tokenizer for:
    - identifiers/english
    - CJK bigrams/trigrams (cheap substitute for word segmentation)
    """
    toks: list[str] = []
    toks.extend(_split_ident_tokens(text))
    for cjk in _iter_cjk_spans(text):
        toks.extend(_cjk_ngrams(cjk, n_min=2, n_max=3))
    # normalize + drop tiny noise
    out: list[str] = []
    for t in toks:
        tt = str(t).strip().lower()
        if not tt:
            continue
        if len(tt) == 1:
            continue
        # Drop very common Chinese function-word blends (keeps recall but reduces noise).
        # Example: "的接口" is usually unhelpful compared to "接口".
        if len(tt) <= 3 and any(ch in tt for ch in ["的", "了", "我", "你", "他", "她", "它", "想", "要", "请"]):
            continue
        out.append(tt)
    return out


def _keyword_boost_tokens(query: str) -> set[str]:
    """
    Add cheap "synonym-ish" tokens when query implies an interface/API hunt.
    This is intentionally conservative and explainable (no LLM required).
    """
    q = (query or "").lower()
    out: set[str] = set()
    if any(k in q for k in ["接口", "api", "endpoint", "rpc", "http", "grpc", "handler", "route", "router"]):
        out |= {
            "api",
            "endpoint",
            "http",
            "grpc",
            "rpc",
            "route",
            "router",
            "handler",
            "client",
            "server",
            "request",
            "response",
            "dispatch",
            "push",
            "pull",
            "send",
            "receive",
            "download",
            "upload",
        }
    if any(k in q for k in ["下发", "下行", "云端", "服务端", "server", "cloud"]):
        out |= {"dispatch", "push", "pull", "deliver", "server", "cloud"}
    return out


def _file_to_module_from_arch(arch: dict[str, Any]) -> dict[str, str]:
    file_to_module: dict[str, str] = {}
    for m in _safe_list(arch.get("modules")):
        if not isinstance(m, dict):
            continue
        mid = str(m.get("module_id") or "").strip()
        if not mid:
            continue
        for p in _safe_list(m.get("files")):
            fp = _norm_path(str(p))
            if fp:
                file_to_module[fp] = mid
    return file_to_module


def _modules_from_arch(arch: dict[str, Any]) -> list[dict[str, Any]]:
    mods = _safe_list(arch.get("modules"))
    out: list[dict[str, Any]] = []
    for m in mods:
        if isinstance(m, dict) and str(m.get("module_id") or "").strip():
            out.append(m)
    return out


def _module_catalog(arch: dict[str, Any], *, cap: int = 200) -> list[dict[str, Any]]:
    """
    Compact module catalog for LLM recall.
    Keep only high-signal fields and cap list length to avoid huge prompts.
    """
    out: list[dict[str, Any]] = []
    for m in _modules_from_arch(arch):
        mid = str(m.get("module_id") or "").strip()
        purpose = str(m.get("purpose") or "")
        summary = str(m.get("summary") or "")
        metrics = _safe_dict(m.get("metrics"))
        kf = _safe_list(m.get("key_files"))
        key_files = []
        for it in kf[:8]:
            if isinstance(it, dict):
                p = str(it.get("path") or "")
                if p:
                    key_files.append(p)
            elif isinstance(it, str):
                key_files.append(it)
        out.append(
            {
                "module_id": mid,
                "purpose": purpose,
                "summary": summary,
                "metrics": {
                    "file_count": int(metrics.get("file_count") or 0),
                    "fan_in": int(metrics.get("fan_in") or 0),
                    "fan_out": int(metrics.get("fan_out") or 0),
                },
                "key_files": key_files,
            }
        )
    # Sort deterministically (we do NOT want hidden heuristics here; only stable ordering).
    out.sort(key=lambda x: str(x.get("module_id") or ""))
    return out[: max(0, int(cap))]


def _file_digest_for_llm(project: ProjectMap, path: str, *, symbols_limit: int = 30, imports_limit: int = 25) -> dict[str, Any]:
    files = project.get("files", {}) if isinstance(project.get("files", {}), dict) else {}
    entry = files.get(path, {}) if isinstance(files.get(path, {}), dict) else {}
    facts = entry.get("facts", {}) if isinstance(entry.get("facts", {}), dict) else {}
    analysis = entry.get("analysis", {}) if isinstance(entry.get("analysis", {}), dict) else {}

    imports: list[str] = []
    for it in _safe_list(facts.get("imports"))[:imports_limit]:
        if isinstance(it, dict):
            mod = str(it.get("module") or "").strip()
            name = str(it.get("name") or "").strip()
            if mod and name and name != "*":
                imports.append(f"{mod}.{name}")
            elif mod:
                imports.append(mod)
            elif name and name != "*":
                imports.append(name)

    syms: list[str] = []
    for s in _safe_list(facts.get("symbols"))[: max(0, int(symbols_limit))]:
        if not isinstance(s, dict):
            continue
        qn = str(s.get("qualname") or s.get("name") or "").strip()
        sig = str(s.get("signature") or "").strip()
        if qn:
            syms.append(qn)
        if sig:
            syms.append(sig)

    return {
        "path": path,
        "language": str(facts.get("language") or ""),
        "size_bytes": int(facts.get("size_bytes") or 0),
        "summary": str(analysis.get("summary") or ""),
        "responsibilities": [str(x) for x in _safe_list(analysis.get("responsibilities"))[:8]],
        "key_interfaces": [str(x) for x in _safe_list(analysis.get("key_interfaces"))[:10]],
        "imports": imports,
        "symbols_sample": syms[:60],
        "analysis_error": str(analysis.get("error") or ""),
    }


def _llm_recall_select_modules(*, query: str, modules: list[dict[str, Any]], max_modules: int) -> dict[str, Any]:
    provider, client, model = load_llm_from_env()

    system = (
        "你是资深代码库检索专家。你的任务是根据 query 从给定的模块列表中挑选最相关的模块。\n"
        "约束：只能选择 INPUT.modules 里出现过的 module_id，不能编造。\n"
        "输出必须是 JSON object。"
    )
    prompt = (
        "请从模块列表中选择最相关的模块（用于后续在这些模块里找接口/入口）。\n\n"
        "输出 JSON Schema:\n"
        "{\n"
        '  "modules": [\n'
        "    {\n"
        '      "module_id": "string",\n'
        '      "confidence": 0.0-1.0,\n'
        '      "reason": "string"\n'
        "    }\n"
        "  ],\n"
        '  "notes": "string"\n'
        "}\n\n"
        f"modules 最多 {max(1, int(max_modules))} 个，按相关性降序。\n"
    )
    payload = {"query": query, "modules": modules}
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt + "\n\nINPUT:\n" + json.dumps(payload, ensure_ascii=False, indent=2)},
    ]
    if provider == "ollama":
        resp = client.chat(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content_ollama(resp)
    else:
        resp = client.chat_completions(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content(resp)
    if not isinstance(obj, dict):
        return {"error": "bad_llm_json", "raw": obj}
    return obj


def _llm_recall_select_files(
    *,
    query: str,
    file_digests: list[dict[str, Any]],
    max_files: int,
) -> dict[str, Any]:
    provider, client, model = load_llm_from_env()

    system = (
        "你是资深代码库检索专家。你的任务是根据 query 从给定的文件摘要列表中挑选最相关的文件。\n"
        "约束：只能选择 INPUT.files 里出现过的 path，不能编造。\n"
        "输出必须是 JSON object。"
    )
    prompt = (
        "请从文件摘要列表中选择最相关的文件（用于后续精确定位接口/入口）。\n\n"
        "输出 JSON Schema:\n"
        "{\n"
        '  "files": [\n'
        "    {\n"
        '      "path": "string",\n'
        '      "confidence": 0.0-1.0,\n'
        '      "reason": "string"\n'
        "    }\n"
        "  ],\n"
        '  "notes": "string"\n'
        "}\n\n"
        f"files 最多 {max(1, int(max_files))} 个，按相关性降序。\n"
    )
    payload = {"query": query, "files": file_digests}
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt + "\n\nINPUT:\n" + json.dumps(payload, ensure_ascii=False, indent=2)},
    ]
    if provider == "ollama":
        resp = client.chat(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content_ollama(resp)
    else:
        resp = client.chat_completions(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content(resp)
    if not isinstance(obj, dict):
        return {"error": "bad_llm_json", "raw": obj}
    return obj


# ---------------------------------------------------------------------------
# Backbone-first locate (deep code reading for precision)
# ---------------------------------------------------------------------------

_BACKBONE_ENTRY_RE = re.compile(r'^\s*\d+\)\s+')


def _parse_backbone_files(md_text: str) -> list[dict[str, str]]:
    """
    Parse backbone_from_report.md to extract file paths and architectural roles.
    Returns list of {"path": ..., "role": ..., "description": ...}.
    """
    results: list[dict[str, str]] = []
    if not md_text:
        return results
    lines = md_text.splitlines()
    i = 0
    while i < len(lines):
        line = lines[i]
        if not _BACKBONE_ENTRY_RE.match(line):
            i += 1
            continue
        # Extract all backtick-quoted file paths from this line
        paths = re.findall(r'`([^`]+\.(?:py|sh|yaml|yml|toml|json|js|ts))`', line)
        role = ""
        description = ""
        # Look at following lines for role and description
        for j in range(i + 1, min(i + 10, len(lines))):
            next_line = lines[j]
            if _BACKBONE_ENTRY_RE.match(next_line):
                break  # next entry
            if not role and '\u67b6\u6784\u89d2\u8272' in next_line:
                # "架构角色" - extract text after the label
                role = re.sub(r'.*\u67b6\u6784\u89d2\u8272[^：:]*[：:]', '', next_line).strip(' *\t\n')
            if not description and '\u6838\u5fc3\u4f5c\u7528' in next_line:
                # "核心作用" - extract text after the label
                description = re.sub(r'.*\u6838\u5fc3\u4f5c\u7528[^：:]*[：:]', '', next_line).strip(' *\t\n')
        for path in paths:
            results.append({
                "path": _norm_path(path),
                "role": role,
                "description": description,
            })
        i += 1
    return results


def _llm_backbone_narrow(
    *,
    cfg: LocateAgentConfig,
    query: str,
    backbone_files: list[dict[str, str]],
    project: ProjectMap,
    max_files: int = 6,
) -> list[dict[str, str]]:
    """
    Phase 1: Given backbone file list + their summaries from project_map,
    ask LLM to select the most relevant files for the query.
    Cheap call (just file summaries, not full source).
    """
    provider, client, model = load_llm_from_env()

    # Build compact file info
    files_info: list[dict[str, Any]] = []
    files_map = project.get("files", {}) if isinstance(project.get("files", {}), dict) else {}
    for bf in backbone_files:
        path = bf["path"]
        entry = files_map.get(path, {}) if isinstance(files_map.get(path, {}), dict) else {}
        analysis = entry.get("analysis", {}) if isinstance(entry.get("analysis", {}), dict) else {}
        files_info.append({
            "path": path,
            "backbone_role": bf.get("role", ""),
            "backbone_description": bf.get("description", ""),
            "summary": str(analysis.get("summary") or ""),
            "key_interfaces": [str(x) for x in _safe_list(analysis.get("key_interfaces"))][:15],
            "responsibilities": [str(x) for x in _safe_list(analysis.get("responsibilities"))][:8],
        })

    system = (
        "\u4f60\u662f\u8d44\u6df1\u4ee3\u7801\u5e93\u68c0\u7d22\u4e13\u5bb6\u3002\u4f60\u7684\u4efb\u52a1\u662f\u6839\u636e query \u4ece\u9879\u76ee\u7684\u4e3b\u7ebf\u4ee3\u7801\u6587\u4ef6\uff08Backbone\uff09\u4e2d\u6311\u9009\u6700\u53ef\u80fd\u5305\u542b\u76ee\u6807\u63a5\u53e3/\u5165\u53e3\u7684\u6587\u4ef6\u3002\n"
        "\u7ea6\u675f\uff1a\u53ea\u80fd\u9009\u62e9 INPUT.files \u91cc\u51fa\u73b0\u8fc7\u7684 path\uff0c\u4e0d\u80fd\u7f16\u9020\u3002\n"
        "\u8f93\u51fa\u5fc5\u987b\u662f JSON object\u3002"
    )
    prompt = (
        "\u8bf7\u4ece\u4e3b\u7ebf\u4ee3\u7801\u6587\u4ef6\u4e2d\u9009\u62e9\u6700\u53ef\u80fd\u5305\u542b\u76ee\u6807\u63a5\u53e3\u7684\u6587\u4ef6\u3002\u540e\u7eed\u4f1a\u9010\u4e2a\u8bfb\u53d6\u5b8c\u6574\u6e90\u7801\u505a\u7cbe\u786e\u5b9a\u4f4d\u3002\n\n"
        "\u8f93\u51fa JSON Schema:\n"
        "{\n"
        '  "files": [\n'
        '    {"path": "string", "confidence": 0.0-1.0, "reason": "string"}\n'
        "  ]\n"
        "}\n\n"
        f"files \u6700\u591a {max(1, int(max_files))} \u4e2a\uff0c\u6309\u76f8\u5173\u6027\u964d\u5e8f\u3002\n"
    )
    payload = {"query": query, "files": files_info}
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt + "\n\nINPUT:\n" + json.dumps(payload, ensure_ascii=False, indent=2)},
    ]
    if provider == "ollama":
        resp = client.chat(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content_ollama(resp)
    else:
        resp = client.chat_completions(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content(resp)
    if not isinstance(obj, dict) or not isinstance(obj.get("files"), list):
        _p(cfg, "Phase 1 (backbone narrow): bad LLM response, keeping all backbone files")
        return backbone_files

    # Map back to backbone_files entries
    selected_paths = set()
    for item in obj["files"]:
        if isinstance(item, dict):
            p = _norm_path(str(item.get("path") or ""))
            if p:
                selected_paths.add(p)

    # Preserve order, keep only selected, limit
    result = [bf for bf in backbone_files if bf["path"] in selected_paths]
    return result[:max(1, int(max_files))]


def _llm_deep_locate_in_file(
    *,
    query: str,
    path: str,
    source_code: str,
    backbone_role: str,
    backbone_description: str,
) -> dict[str, Any]:
    """
    Phase 2: Send FULL source code (with line numbers) to LLM.
    Ask it to find exact interfaces/functions/locations matching the query.
    This is the precision-critical step.
    """
    provider, client, model = load_llm_from_env()

    system = (
        "\u4f60\u662f\u8d44\u6df1\u4ee3\u7801\u5b9a\u4f4d\u4e13\u5bb6\u3002\u4f60\u7684\u4efb\u52a1\u662f\u5728\u7ed9\u5b9a\u7684\u5b8c\u6574\u6e90\u4ee3\u7801\u4e2d\u7cbe\u786e\u5b9a\u4f4d\u4e0e query \u76f8\u5173\u7684\u63a5\u53e3\u3001\u51fd\u6570\u3001\u65b9\u6cd5\u6216\u7c7b\u3002\n"
        "\u4f60\u5fc5\u987b\u4e25\u683c\u57fa\u4e8e\u63d0\u4f9b\u7684\u6e90\u4ee3\u7801\uff0c\u7ed9\u51fa\u7cbe\u786e\u7684\u884c\u53f7\u548c\u4ee3\u7801\u4f4d\u7f6e\u3002\n"
        "\u4e0d\u8981\u7f16\u9020\u4efb\u4f55\u4e0d\u5b58\u5728\u4e8e\u6e90\u4ee3\u7801\u4e2d\u7684\u5185\u5bb9\u3002\n"
        "\u8f93\u51fa\u5fc5\u987b\u662f JSON object\u3002"
    )
    prompt = (
        f"Query: {query}\n\n"
        f"\u6587\u4ef6\u8def\u5f84: {path}\n"
        f"\u67b6\u6784\u89d2\u8272: {backbone_role}\n"
        f"\u6838\u5fc3\u4f5c\u7528: {backbone_description}\n\n"
        "\u8bf7\u5728\u4e0b\u65b9\u6e90\u4ee3\u7801\u4e2d\u627e\u51fa\u4e0e query \u6700\u76f8\u5173\u7684\u63a5\u53e3/\u51fd\u6570/\u65b9\u6cd5/\u7c7b\u3002\n\n"
        "\u8f93\u51fa JSON Schema:\n"
        "{\n"
        '  "matches": [\n'
        "    {\n"
        '      "name": "string",\n'
        '      "qualname": "string",\n'
        '      "kind": "function | method | class | variable",\n'
        '      "start_line": int,\n'
        '      "end_line": int,\n'
        '      "signature": "string",\n'
        '      "confidence": 0.0-1.0,\n'
        '      "reason": "string"\n'
        "    }\n"
        "  ],\n"
        '  "file_relevance": 0.0-1.0,\n'
        '  "notes": "string"\n'
        "}\n\n"
        "\u7ea6\u675f\uff1a\n"
        "- matches \u6700\u591a 8 \u4e2a\uff0c\u6309\u76f8\u5173\u6027\u964d\u5e8f\n"
        "- start_line/end_line \u5fc5\u987b\u662f\u6e90\u4ee3\u7801\u4e2d\u7684\u771f\u5b9e\u884c\u53f7\uff08\u5de6\u4fa7\u6709\u884c\u53f7\u6807\u6ce8\uff09\n"
        "- \u5982\u679c\u8be5\u6587\u4ef6\u4e0e query \u65e0\u5173\uff0c\u8fd4\u56de\u7a7a\u6570\u7ec4 matches=[] \u548c file_relevance=0\n"
        "- confidence \u8868\u793a\u4f60\u5bf9\u201c\u8fd9\u5c31\u662f\u7528\u6237\u8981\u627e\u7684\u63a5\u53e3\u201d\u7684\u628a\u63e1\n\n"
        "=== \u6e90\u4ee3\u7801 ===\n"
    )

    # Add line numbers to source code
    src_lines = source_code.splitlines()
    numbered = "\n".join(f"{i + 1:6d}| {line}" for i, line in enumerate(src_lines))

    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt + numbered},
    ]
    if provider == "ollama":
        resp = client.chat(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content_ollama(resp)
    else:
        resp = client.chat_completions(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content(resp)
    if not isinstance(obj, dict):
        return {"error": "bad_llm_json", "raw": obj}
    return obj


def _backbone_locate(
    cfg: LocateAgentConfig,
    project: ProjectMap,
    idx: ProjectIndex,
    arch: dict[str, Any],
) -> dict[str, Any]:
    """
    Backbone-first locate: use backbone analysis to scope search,
    then deep-read FULL source code for precise location.

    Flow:
      Phase 1 (cheap):  query + backbone file summaries -> select relevant files
      Phase 2 (precise): for each file, read FULL source + LLM deep locate
    """
    query = cfg.query
    root = _project_root(project)
    file_to_module = _file_to_module_from_arch(arch)

    # --- Read and parse backbone ---
    _p(cfg, f"reading backbone: {cfg.backbone_md_path}")
    try:
        backbone_md = read_text_file(cfg.backbone_md_path, max_bytes=2_000_000)
    except Exception as e:
        _p(cfg, f"ERROR: cannot read backbone: {e}")
        return {
            "meta": {"error": f"Cannot read backbone: {e}"},
            "query": query,
            "candidates": [],
            "markdown": f"## LocateAgent\n\nError: cannot read backbone file `{cfg.backbone_md_path}`: {e}\n",
        }

    backbone_files = _parse_backbone_files(backbone_md)
    _p(cfg, f"parsed {len(backbone_files)} backbone files: {[f['path'] for f in backbone_files]}")

    if not backbone_files:
        _p(cfg, "WARNING: no backbone files found in backbone markdown")
        return {
            "meta": {"error": "No backbone files found"},
            "query": query,
            "candidates": [],
            "markdown": "## LocateAgent\n\nNo backbone files found in the markdown.\n",
        }

    # --- Phase 1: Narrow down backbone files ---
    max_narrow = min(int(cfg.llm_max_files), len(backbone_files))
    if len(backbone_files) > max_narrow:
        _p(cfg, f"Phase 1: narrowing {len(backbone_files)} backbone files to ~{max_narrow}")
        try:
            selected = _llm_backbone_narrow(
                cfg=cfg,
                query=query,
                backbone_files=backbone_files,
                project=project,
                max_files=max_narrow,
            )
        except Exception as e:
            _p(cfg, f"Phase 1 LLM failed: {e}, using all backbone files")
            selected = backbone_files[:max_narrow]
    else:
        selected = backbone_files
    _p(cfg, f"Phase 1 result: {len(selected)} files selected: {[f['path'] for f in selected]}")

    # --- Phase 2: Deep locate (read full source, concurrent LLM calls) ---
    _p(cfg, f"Phase 2: reading full source code for {len(selected)} files")
    file_sources: dict[str, str] = {}
    for bf in selected:
        abs_path = os.path.join(root, bf["path"].replace("/", os.sep))
        try:
            src = read_text_file(abs_path, max_bytes=2_000_000)
            file_sources[bf["path"]] = src
            _p(cfg, f"  read {bf['path']}: {len(src)} chars, {len(src.splitlines())} lines")
        except Exception as e:
            _p(cfg, f"  WARNING: cannot read {bf['path']}: {e}")

    max_workers = max(1, int(cfg.llm_concurrency))
    _p(cfg, f"Phase 2: deep locating in {len(file_sources)} files (concurrency={max_workers})")

    deep_results: dict[str, dict[str, Any]] = {}
    bf_map = {bf["path"]: bf for bf in selected}

    with ThreadPoolExecutor(max_workers=max_workers) as ex:
        futs = {}
        for path, src in file_sources.items():
            bf = bf_map.get(path, {})
            futs[ex.submit(
                _llm_deep_locate_in_file,
                query=query,
                path=path,
                source_code=src,
                backbone_role=str(bf.get("role", "")),
                backbone_description=str(bf.get("description", "")),
            )] = path
        done_iter = as_completed(futs)
        done_iter2 = _wrap_iter(cfg, done_iter, total=len(futs), desc="deep locate")
        for fut in done_iter2:
            p = futs[fut]
            try:
                deep_results[p] = fut.result()
            except Exception as e:
                deep_results[p] = {"error": f"LLMError: {e}"}

    # --- Build candidates ---
    candidates: list[dict[str, Any]] = []
    for bf in selected:
        path = bf["path"]
        dr = deep_results.get(path, {})
        file_relevance = float(dr.get("file_relevance", 0))
        matches = _safe_list(dr.get("matches"))
        notes = str(dr.get("notes", ""))
        error = str(dr.get("error", ""))

        # File meta from project_map
        files_map = project.get("files", {}) if isinstance(project.get("files", {}), dict) else {}
        entry = files_map.get(path, {}) if isinstance(files_map.get(path, {}), dict) else {}
        facts = entry.get("facts", {}) if isinstance(entry.get("facts", {}), dict) else {}
        analysis = entry.get("analysis", {}) if isinstance(entry.get("analysis", {}), dict) else {}

        candidate: dict[str, Any] = {
            "path": path,
            "module_id": str(file_to_module.get(_norm_path(path), "")),
            "backbone_role": bf.get("role", ""),
            "backbone_description": bf.get("description", ""),
            "score": round(file_relevance, 4),
            "why": ["backbone"],
            "language": str(facts.get("language") or ""),
            "key_interfaces": [str(x) for x in _safe_list(analysis.get("key_interfaces"))][:15],
            "imports": [],
            "interfaces": [],
            "deep_locate": {
                "matches": matches,
                "file_relevance": file_relevance,
                "notes": notes,
                "error": error,
            },
        }

        # Read code excerpt for each match (provide evidence to user)
        src = file_sources.get(path, "")
        src_lines = src.splitlines() if src else []
        for m in matches:
            if not isinstance(m, dict):
                continue
            sl = int(m.get("start_line", 0))
            el = int(m.get("end_line", 0))
            if sl > 0 and el >= sl and src_lines:
                excerpt = "\n".join(src_lines[max(0, sl - 1):min(len(src_lines), el)])
                m["excerpt"] = excerpt

        candidates.append(candidate)

    # Sort by file_relevance descending
    candidates.sort(key=lambda c: -float(c.get("score", 0)))

    meta = {
        "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "agent": INTERNAL_NAME,
        "agent_display_name": DISPLAY_NAME,
        "project_map_path": os.path.abspath(cfg.project_map_path),
        "architecture_json_path": os.path.abspath(cfg.architecture_json_path),
        "backbone_md_path": os.path.abspath(cfg.backbone_md_path),
        "mode": "backbone",
        "backbone_files_total": len(backbone_files),
        "backbone_files_selected": len(selected),
        "query": query,
        "recall": {"mode": "backbone", "used": "backbone"},
    }

    out: dict[str, Any] = {"meta": meta, "query": query, "candidates": candidates}
    out["markdown"] = _render_backbone_markdown(out, backbone_md)
    _p(cfg, "done (backbone mode)")
    return out


def _render_backbone_markdown(result: dict[str, Any], backbone_md: str) -> str:
    """Render backbone-mode results as markdown with code evidence."""
    query = str(result.get("query") or "")
    meta = _safe_dict(result.get("meta"))
    cands = _safe_list(result.get("candidates"))

    lines: list[str] = []
    lines.append("## LocateAgent result (Backbone mode)")
    lines.append("")
    lines.append(f"- **Query**: {query}")
    lines.append(f"- **Mode**: backbone (deep code reading)")
    lines.append(f"- **Generated at**: `{meta.get('generated_at', '')}`")
    lines.append(f"- **Backbone files scanned**: {meta.get('backbone_files_selected', '?')}/{meta.get('backbone_files_total', '?')}")
    lines.append("")

    if not cands:
        lines.append("No relevant interfaces found in backbone files.")
        return "\n".join(lines)

    # Filter to candidates with matches
    has_matches = [c for c in cands if _safe_list(_safe_dict(c.get("deep_locate")).get("matches"))]
    no_matches = [c for c in cands if not _safe_list(_safe_dict(c.get("deep_locate")).get("matches"))]

    if has_matches:
        lines.append("## Matched files (with precise locations)")
        lines.append("")
        for i, c in enumerate(has_matches, start=1):
            path = str(c.get("path", ""))
            role = str(c.get("backbone_role", ""))
            dl = _safe_dict(c.get("deep_locate"))
            relevance = dl.get("file_relevance", 0)
            notes = str(dl.get("notes", ""))
            matches = _safe_list(dl.get("matches"))

            lines.append(f"### {i}. `{path}`")
            if role:
                lines.append(f"- **Backbone role**: {role}")
            lines.append(f"- **File relevance**: {relevance}")
            if notes:
                lines.append(f"- **Notes**: {notes}")
            lines.append("")

            for j, m in enumerate(matches, start=1):
                if not isinstance(m, dict):
                    continue
                qn = str(m.get("qualname") or m.get("name") or "")
                kind = str(m.get("kind", ""))
                sig = str(m.get("signature", ""))
                sl = m.get("start_line", "?")
                el = m.get("end_line", "?")
                conf = m.get("confidence", "?")
                reason = str(m.get("reason", ""))
                excerpt = str(m.get("excerpt", ""))

                lines.append(f"**{j}. `{qn}`** ({kind}) @ `{path}:L{sl}-L{el}` | confidence={conf}")
                if sig:
                    lines.append(f"- signature: `{sig}`")
                if reason:
                    lines.append(f"- reason: {reason}")
                if excerpt:
                    lines.append("")
                    lines.append("```python")
                    # Add line numbers to excerpt
                    start = int(sl) if isinstance(sl, int) else 1
                    for k, eline in enumerate(excerpt.splitlines()):
                        lines.append(f"{start + k:6d}| {eline}")
                    lines.append("```")
                lines.append("")
            lines.append("---")
            lines.append("")

    if no_matches:
        lines.append("## Scanned but no matches")
        lines.append("")
        for c in no_matches:
            path = str(c.get("path", ""))
            role = str(c.get("backbone_role", ""))
            dl = _safe_dict(c.get("deep_locate"))
            err = str(dl.get("error", ""))
            lines.append(f"- `{path}` ({role})")
            if err:
                lines.append(f"  - error: {err}")
        lines.append("")

    return "\n".join(lines)


def _file_doc_text(project: ProjectMap, path: str) -> tuple[str, dict[str, Any]]:
    files = project.get("files", {}) if isinstance(project.get("files", {}), dict) else {}
    entry = files.get(path, {}) if isinstance(files.get(path, {}), dict) else {}
    facts = entry.get("facts", {}) if isinstance(entry.get("facts", {}), dict) else {}
    analysis = entry.get("analysis", {}) if isinstance(entry.get("analysis", {}), dict) else {}

    # Keep this small & high-signal.
    summary = str(analysis.get("summary") or "")
    resp = [str(x) for x in _safe_list(analysis.get("responsibilities"))][:12]
    kis = [str(x) for x in _safe_list(analysis.get("key_interfaces"))][:20]
    deps = [str(x) for x in _safe_list(analysis.get("external_dependencies"))][:15]
    risks = [str(x) for x in _safe_list(analysis.get("risks"))][:10]

    imports: list[str] = []
    for it in _safe_list(facts.get("imports"))[:80]:
        if isinstance(it, dict):
            mod = str(it.get("module") or "").strip()
            name = str(it.get("name") or "").strip()
            if mod and name and name != "*":
                imports.append(f"{mod}.{name}")
            elif mod:
                imports.append(mod)
            elif name and name != "*":
                imports.append(name)

    # symbols: qualname/signature/doc_firstline
    sym_texts: list[str] = []
    for s in _safe_list(facts.get("symbols"))[:800]:
        if not isinstance(s, dict):
            continue
        sym_texts.append(str(s.get("qualname") or s.get("name") or ""))
        sym_texts.append(str(s.get("signature") or ""))
        sym_texts.append(str(s.get("doc_firstline") or ""))

    parts = [
        f"path: {path}",
        f"summary: {summary}",
        "responsibilities: " + " | ".join(resp),
        "key_interfaces: " + " | ".join(kis),
        "imports: " + " | ".join(imports[:60]),
        "symbols: " + " | ".join([x for x in sym_texts if x][:200]),
        "external_dependencies: " + " | ".join(deps),
        "risks: " + " | ".join(risks),
    ]
    meta = {
        "imports": imports,
        "key_interfaces": kis,
        "analysis_error": str(analysis.get("error") or ""),
        "language": str(facts.get("language") or ""),
    }
    return "\n".join(parts), meta


def _symbol_candidates_for_file(project: ProjectMap, path: str) -> list[SymbolDef]:
    files = project.get("files", {}) if isinstance(project.get("files", {}), dict) else {}
    entry = files.get(path, {}) if isinstance(files.get(path, {}), dict) else {}
    facts = entry.get("facts", {}) if isinstance(entry.get("facts", {}), dict) else {}
    syms = facts.get("symbols", []) if isinstance(facts.get("symbols", []), list) else []
    out: list[SymbolDef] = []
    for s in syms:
        if isinstance(s, dict):
            out.append(s)  # type: ignore[arg-type]
    return out


def _project_root(project: ProjectMap) -> str:
    meta = project.get("meta", {}) if isinstance(project.get("meta", {}), dict) else {}
    root = str(meta.get("root") or ".")
    return os.path.abspath(root)


def _read_file_lines_best_effort(abs_path: str, *, max_bytes: int = 2_000_000) -> list[str]:
    """
    Read source file as lines. Best-effort, capped.
    """
    try:
        txt = read_text_file(abs_path, max_bytes=max_bytes)
    except Exception:
        return []
    # keepends=False makes line slicing easier
    return txt.splitlines()


def _slice_lines(lines: list[str], start_line: int, end_line: int, *, max_lines: int = 260) -> str:
    if not lines:
        return ""
    sl = max(1, int(start_line))
    el = max(sl, int(end_line))
    if el - sl + 1 > max_lines:
        el = sl + max_lines - 1
    # 1-based -> 0-based
    seg = lines[sl - 1 : min(len(lines), el)]
    return "\n".join(seg)


def _build_code_evidence(
    *,
    root: str,
    path: str,
    symbols: list[SymbolDef],
    max_symbols: int,
    max_excerpt_tokens: int,
) -> dict[str, Any]:
    """
    Build evidence payload for one file:
    - head imports/context
    - symbol excerpts (bounded)
    """
    abs_path = os.path.join(root, path.replace("/", os.sep))
    lines = _read_file_lines_best_effort(abs_path)
    head = "\n".join(lines[:140]) if lines else ""

    # Select candidate symbols: prefer those with signatures and non-dunder names.
    def sym_key(s: SymbolDef) -> tuple[int, int, str]:
        name = str(s.get("name") or "").strip()
        dunder = 1 if (name.startswith("__") and name.endswith("__")) else 0
        sig_missing = 1 if not str(s.get("signature") or "").strip() else 0
        sp = _safe_dict(s.get("span"))
        sl = int(sp.get("start_line") or 10**9)
        return (dunder, sig_missing, f"{sl:09d}:{name}")

    picked = sorted(symbols, key=sym_key)[: max(0, int(max_symbols))]

    sym_items: list[dict[str, Any]] = []
    budget = max(200, int(max_excerpt_tokens))
    used = estimate_tokens(head).approx_tokens if head else 0

    for s in picked:
        sp = _safe_dict(s.get("span"))
        sl = int(sp.get("start_line") or 1)
        el = int(sp.get("end_line") or sl)
        excerpt = _slice_lines(lines, sl, el, max_lines=260)
        if excerpt:
            est = estimate_tokens(excerpt).approx_tokens
            if used + est > budget:
                # stop adding more excerpts once we exceed budget
                break
            used += est
        sym_items.append(
            {
                "symbol_id": str(s.get("symbol_id") or ""),
                "kind": str(s.get("kind") or ""),
                "name": str(s.get("name") or ""),
                "qualname": str(s.get("qualname") or s.get("name") or ""),
                "signature": str(s.get("signature") or ""),
                "span": sp,
                "doc_firstline": str(s.get("doc_firstline") or ""),
                "excerpt": excerpt,
            }
        )

    return {
        "path": path,
        "abs_path": abs_path,
        "head": head,
        "symbols": sym_items,
        "lines_count": len(lines),
    }


def _llm_refine_file(
    *,
    query: str,
    file_candidate: dict[str, Any],
    evidence: dict[str, Any],
) -> dict[str, Any]:
    """
    Ask LLM to select the best matching interfaces in this file *from provided evidence*.
    Must not invent paths/line numbers; only choose among provided symbols/spans.
    """
    provider, client, model = load_llm_from_env()

    system = (
        "你是资深代码定位与接口分析专家。你的任务是：根据 query，在给定的文件证据里找出最可能相关的接口/函数/方法。\n"
        "你必须严格遵守约束：\n"
        "- 只能从 INPUT.evidence.symbols 列表中选择（不要编造符号/路径/行号）\n"
        "- span 必须原样返回（start_line/end_line）\n"
        "- 如果证据不足，请输出空数组 matches=[]\n"
        "输出必须是 JSON object。"
    )
    prompt = (
        "请根据 query 定位“最相关的接口/入口”。\n\n"
        "输出 JSON Schema:\n"
        "{\n"
        '  "matches": [\n'
        "    {\n"
        '      "symbol_id": "string",\n'
        '      "qualname": "string",\n'
        '      "signature": "string",\n'
        '      "span": {"start_line": int, "end_line": int},\n'
        '      "confidence": 0.0-1.0,\n'
        '      "reason": "string"\n'
        "    }\n"
        "  ],\n"
        '  "notes": "string"\n'
        "}\n\n"
        "约束：matches 最多 6 个；confidence 按你对“就是它”的把握给分。\n"
    )

    user_payload = {
        "query": query,
        "candidate": {
            "path": file_candidate.get("path", ""),
            "module_id": file_candidate.get("module_id", ""),
            "score": file_candidate.get("score", 0),
            "why": file_candidate.get("why", []),
            "language": file_candidate.get("language", ""),
            "key_interfaces": file_candidate.get("key_interfaces", []),
            "imports": file_candidate.get("imports", []),
        },
        "evidence": {
            "path": evidence.get("path", ""),
            "head": evidence.get("head", ""),
            "symbols": evidence.get("symbols", []),
        },
    }

    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt + "\n\nINPUT:\n" + json.dumps(user_payload, ensure_ascii=False, indent=2)},
    ]

    if provider == "ollama":
        resp = client.chat(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content_ollama(resp)
    else:
        resp = client.chat_completions(model=model, messages=messages)  # type: ignore[attr-defined]
        obj = extract_json_content(resp)
    if not isinstance(obj, dict):
        return {"error": "bad_llm_json", "raw": obj}
    return obj


def _idf(n_docs: int, df: int) -> float:
    # Smooth idf; always positive.
    return math.log((n_docs + 1.0) / (df + 1.0)) + 1.0


def _score_overlap(query_tokens: list[str], doc_tokens: set[str], *, n_docs: int, dfs: dict[str, int]) -> tuple[float, list[str]]:
    score = 0.0
    reasons: list[str] = []
    for t in query_tokens:
        if t in doc_tokens:
            w = _idf(n_docs, int(dfs.get(t, 0)))
            score += w
            reasons.append(t)
    # Prefer concise reasons.
    reasons = sorted(set(reasons), key=lambda x: (-len(x), x))[:16]
    return score, reasons


def _score_symbol(query_tokens: list[str], sym: SymbolDef, *, dfs: dict[str, int], n_docs: int, query: str) -> tuple[float, list[str]]:
    qn = str(sym.get("qualname") or sym.get("name") or "")
    sig = str(sym.get("signature") or "")
    doc = " ".join([qn, sig, str(sym.get("doc_firstline") or "")])
    toks = set(tokenize(doc))
    base, why = _score_overlap(query_tokens, toks, n_docs=n_docs, dfs=dfs)

    # Interface bias when query looks like "find the API".
    q = (query or "").lower()
    name = str(sym.get("name") or "").lower()
    if any(k in q for k in ["接口", "api", "endpoint", "rpc", "http", "grpc"]):
        if any(k in name for k in ["api", "endpoint", "handler", "router", "route", "client", "server", "request"]):
            base *= 1.25
        # dunder methods are rarely the "interface" user wants
        if name.startswith("__") and name.endswith("__"):
            base *= 0.40
    return base, why


def locate_query(cfg: LocateAgentConfig) -> dict[str, Any]:
    query = (cfg.query or "").strip()
    if not query:
        raise ValueError("query is empty")

    _p(cfg, f"loading project_map: {cfg.project_map_path}")
    project_obj = _read_json(cfg.project_map_path)
    if project_obj is None:
        raise FileNotFoundError(f"Cannot read project_map.json: {cfg.project_map_path}")
    project: ProjectMap = project_obj  # type: ignore[assignment]
    _p(cfg, "building project index")
    idx: ProjectIndex = build_index(project)

    _p(cfg, f"loading architecture json: {cfg.architecture_json_path} (optional)")
    arch = _read_json(cfg.architecture_json_path) or {}

    # --- Backbone mode: early return with deep code reading ---
    if str(cfg.recall_mode) == "backbone":
        _p(cfg, "recall_mode=backbone: using backbone-first deep locate")
        return _backbone_locate(cfg, project, idx, arch)

    file_to_module = _file_to_module_from_arch(arch)
    arch_modules = _modules_from_arch(arch)

    recall_info: dict[str, Any] = {"mode": str(cfg.recall_mode), "used": "heuristic"}

    # Lazy-built heuristic index (only needed in heuristic/hybrid fill).
    file_docs: list[dict[str, Any]] = []
    query_tokens: list[str] = []
    dfs: dict[str, int] = {}
    n_docs = 1

    def ensure_heuristic_index() -> None:
        nonlocal file_docs, query_tokens, dfs, n_docs
        if file_docs:
            return
        # Build per-file token sets (high-signal doc text).
        _p(cfg, f"building file docs/tokens for {len(idx.all_files)} files")
        all_files_sorted = sorted(idx.all_files)
        for p in _wrap_iter(cfg, all_files_sorted, total=len(all_files_sorted), desc="file docs"):
            text, meta = _file_doc_text(project, p)
            toks = set(tokenize(text))
            file_docs.append(
                {
                    "path": p,
                    "module_id": file_to_module.get(_norm_path(p), ""),
                    "text": text,
                    "tokens": toks,
                    "meta": meta,
                }
            )

        # Query tokens (+ heuristic boosts).
        qt = tokenize(query)
        boosts = sorted(_keyword_boost_tokens(query))
        qt = qt + [b.lower() for b in boosts if b]
        # de-dupe but keep order
        seen: set[str] = set()
        for t in qt:
            if t in seen:
                continue
            seen.add(t)
            query_tokens.append(t)

        # Compute df for query tokens only (cheap).
        n_docs = max(1, len(file_docs))
        dfs = {t: 0 for t in query_tokens}
        _p(cfg, "computing document frequencies for query tokens")
        for d in file_docs:
            toks = d["tokens"]
            for t in query_tokens:
                if t in toks:
                    dfs[t] = int(dfs.get(t, 0)) + 1

    def _candidate_from_path(
        *,
        path: str,
        score: float,
        why: list[str],
        symbol_mode: str,
    ) -> dict[str, Any]:
        """
        Build a candidate object without requiring heuristic token index.

        symbol_mode:
          - "query": score symbols against query_tokens/dfs (requires ensure_heuristic_index)
          - "structural": choose symbols by structural heuristics (signature/non-dunder), no query scoring
        """
        path = _norm_path(path)
        syms = _symbol_candidates_for_file(project, path)

        interfaces: list[dict[str, Any]] = []
        if symbol_mode == "query":
            ensure_heuristic_index()
            sym_scored: list[tuple[float, SymbolDef, list[str]]] = []
            for sym in syms:
                ss, swhy = _score_symbol(query_tokens, sym, dfs=dfs, n_docs=n_docs, query=query)
                if ss <= 0:
                    continue
                sym_scored.append((ss, sym, swhy))
            sym_scored.sort(key=lambda x: (-x[0], str(x[1].get("qualname") or x[1].get("name") or "")))
            sym_scored = sym_scored[: max(0, int(cfg.top_symbols))]
            for ss, sym, swhy in sym_scored:
                sp = _safe_dict(sym.get("span"))
                interfaces.append(
                    {
                        "symbol_id": str(sym.get("symbol_id") or ""),
                        "kind": str(sym.get("kind") or ""),
                        "qualname": str(sym.get("qualname") or sym.get("name") or ""),
                        "signature": str(sym.get("signature") or ""),
                        "path": _norm_path(str(sym.get("path") or path)),
                        "span": sp,
                        "score": round(float(ss), 4),
                        "why": swhy[:10],
                    }
                )
        else:
            # Structural-only: prefer non-dunder + has signature + earlier span.
            def sym_key(s: SymbolDef) -> tuple[int, int, int, str]:
                name = str(s.get("name") or "").strip()
                dunder = 1 if (name.startswith("__") and name.endswith("__")) else 0
                sig_missing = 1 if not str(s.get("signature") or "").strip() else 0
                sp = _safe_dict(s.get("span"))
                sl = int(sp.get("start_line") or 10**9)
                qn = str(s.get("qualname") or s.get("name") or "")
                return (dunder, sig_missing, sl, qn)

            picked = sorted([s for s in syms if isinstance(s, dict)], key=sym_key)[: max(0, int(cfg.top_symbols))]
            for sym in picked:
                sp = _safe_dict(sym.get("span"))
                interfaces.append(
                    {
                        "symbol_id": str(sym.get("symbol_id") or ""),
                        "kind": str(sym.get("kind") or ""),
                        "qualname": str(sym.get("qualname") or sym.get("name") or ""),
                        "signature": str(sym.get("signature") or ""),
                        "path": _norm_path(str(sym.get("path") or path)),
                        "span": sp,
                        "score": 0.0,
                        "why": ["structural"],
                    }
                )

        # Pull file-level meta directly from project_map (no token index required).
        files = project.get("files", {}) if isinstance(project.get("files", {}), dict) else {}
        entry = files.get(path, {}) if isinstance(files.get(path, {}), dict) else {}
        facts = entry.get("facts", {}) if isinstance(entry.get("facts", {}), dict) else {}
        analysis = entry.get("analysis", {}) if isinstance(entry.get("analysis", {}), dict) else {}

        imports: list[str] = []
        for it in _safe_list(facts.get("imports"))[:80]:
            if isinstance(it, dict):
                mod = str(it.get("module") or "").strip()
                name = str(it.get("name") or "").strip()
                if mod and name and name != "*":
                    imports.append(f"{mod}.{name}")
                elif mod:
                    imports.append(mod)
                elif name and name != "*":
                    imports.append(name)

        kis = [str(x) for x in _safe_list(analysis.get("key_interfaces"))][:20]

        return {
            "path": path,
            "module_id": str(file_to_module.get(path, "")),
            "score": round(float(score), 4),
            "why": why,
            "language": str(facts.get("language") or ""),
            "analysis_error": str(analysis.get("error") or ""),
            "key_interfaces": kis,
            "imports": imports[:40],
            "interfaces": interfaces,
        }

    def heuristic_rank() -> list[tuple[float, dict[str, Any], list[str]]]:
        ensure_heuristic_index()
        scored: list[tuple[float, dict[str, Any], list[str]]] = []
        for d in file_docs:
            s, reasons = _score_overlap(query_tokens, d["tokens"], n_docs=n_docs, dfs=dfs)
            # Extra boost if raw query appears in text (for long CJK phrases).
            if query and query in d["text"]:
                s *= 1.15
                if "contains:query" not in reasons:
                    reasons = (reasons + ["contains:query"])[:16]
            # Small boost if path includes key tokens.
            lp = d["path"].lower()
            if any(t in lp for t in query_tokens if len(t) >= 3):
                s *= 1.10

            # When query looks like "find an interface", prefer code files over CI/docs/configs.
            ql = query.lower()
            lang = str(_safe_dict(d.get("meta")).get("language") or "")
            if any(k in ql for k in ["接口", "api", "endpoint", "rpc", "http", "grpc"]):
                if lang in {"python", "javascript", "typescript"}:
                    s *= 1.20
                elif lang in {"yaml", "markdown", "text", "json"}:
                    s *= 0.65

            if s >= float(cfg.min_score):
                scored.append((s, d, reasons))
        scored.sort(key=lambda x: (-x[0], x[1]["path"]))
        return scored

    # LLM-based recall (module -> files), best-effort.
    llm_ranked_paths: list[tuple[float, str, str]] = []  # (score,path,reason)
    if str(cfg.recall_mode) in {"llm", "hybrid"} and bool(cfg.llm_enabled) and arch_modules:
        try:
            _p(cfg, "LLM recall: selecting modules")
            catalog = _module_catalog(arch, cap=200)
            msel = _llm_recall_select_modules(query=query, modules=catalog, max_modules=int(cfg.llm_recall_max_modules))
            recall_info["llm_modules"] = msel
            if isinstance(msel, dict) and isinstance(msel.get("modules"), list):
                chosen = [str(x.get("module_id") or "") for x in msel.get("modules", []) if isinstance(x, dict) and str(x.get("module_id") or "")]
                chosen = [m for m in chosen if any(mm.get("module_id") == m for mm in catalog)]
                chosen = chosen[: max(0, int(cfg.llm_recall_max_modules))]
            else:
                chosen = []

            # Collect files from chosen modules
            module_by_id = {str(m.get("module_id") or ""): m for m in arch_modules}
            allowed_files: list[str] = []
            for mid in chosen:
                m = module_by_id.get(mid) or {}
                for p in _safe_list(_safe_dict(m).get("files")):
                    fp = _norm_path(str(p))
                    if fp:
                        allowed_files.append(fp)
            # Dedupe preserve order
            seenp: set[str] = set()
            allowed = []
            for p in allowed_files:
                if p in seenp:
                    continue
                seenp.add(p)
                allowed.append(p)

            # Build digests and ask LLM to pick files
            dig_cap = max(1, int(cfg.llm_recall_file_digest_limit))
            _p(cfg, f"LLM recall: building file digests (cap={dig_cap})")
            digests = [_file_digest_for_llm(project, p) for p in allowed[:dig_cap] if p in idx.all_files]
            _p(cfg, f"LLM recall: selecting files from {len(digests)} digests")
            fsel = _llm_recall_select_files(query=query, file_digests=digests, max_files=int(cfg.llm_recall_max_files))
            recall_info["llm_files"] = fsel
            picked: list[str] = []
            reasons_map: dict[str, str] = {}
            if isinstance(fsel, dict) and isinstance(fsel.get("files"), list):
                for it in fsel.get("files", []):
                    if not isinstance(it, dict):
                        continue
                    p = _norm_path(str(it.get("path") or ""))
                    if p and any(d.get("path") == p for d in digests):
                        picked.append(p)
                        reasons_map[p] = str(it.get("reason") or "")
            picked = picked[: max(0, int(cfg.top_files))]
            for i, p in enumerate(picked):
                # synthetic score: descending with confidence hint if present
                conf = 0.9 - 0.02 * i
                llm_ranked_paths.append((conf, p, reasons_map.get(p, "")))
            if llm_ranked_paths:
                recall_info["used"] = "llm"
        except Exception as e:
            recall_info["llm_error"] = f"{e}"

    candidates: list[dict[str, Any]] = []
    if recall_info.get("used") == "llm" and llm_ranked_paths:
        _p(cfg, f"assembling candidates from LLM recall ({len(llm_ranked_paths)} files)")
        for score, p, reason in llm_ranked_paths[: max(0, int(cfg.top_files))]:
            why = ["llm:recall"] + ([reason] if reason else [])
            candidates.append(_candidate_from_path(path=p, score=float(score), why=why, symbol_mode="structural"))
        # If hybrid, fill remaining with heuristic
        if str(cfg.recall_mode) == "hybrid" and len(candidates) < max(0, int(cfg.top_files)):
            hr = heuristic_rank()
            picked_set = {str(c.get("path") or "") for c in candidates if isinstance(c, dict)}
            for s, d, rs in hr:
                if len(candidates) >= max(0, int(cfg.top_files)):
                    break
                p = str(d.get("path") or "")
                if p in picked_set:
                    continue
                candidates.append(_candidate_from_path(path=p, score=float(s), why=rs, symbol_mode="query"))
    else:
        # If user requested pure LLM recall, do not fall back.
        if str(cfg.recall_mode) == "llm" and bool(cfg.llm_recall_strict):
            _p(cfg, "LLM recall failed and strict mode is on; returning no candidates")
        else:
            # heuristic-only
            hr = heuristic_rank()[: max(0, int(cfg.top_files))]
            for s, d, rs in hr:
                p = str(d.get("path") or "")
                candidates.append(_candidate_from_path(path=p, score=float(s), why=rs, symbol_mode="query"))

    meta = {
        "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "agent": INTERNAL_NAME,
        "agent_display_name": DISPLAY_NAME,
        "project_map_path": os.path.abspath(cfg.project_map_path),
        "architecture_json_path": os.path.abspath(cfg.architecture_json_path),
        "query_tokens": query_tokens[:50] if query_tokens else [],
        "top_files": int(cfg.top_files),
        "top_symbols": int(cfg.top_symbols),
        "recall": recall_info,
    }

    out = {"meta": meta, "query": query, "candidates": candidates}
    # Optional LLM refinement: re-read code and select best interfaces.
    if bool(cfg.llm_enabled) and candidates:
        try:
            root = _project_root(project)
            max_files = max(0, int(cfg.llm_max_files))
            llm_targets = candidates[: (max_files if max_files else len(candidates))]

            # Prepare evidence first (read source code).
            _p(cfg, f"LLM refine: reading source + building evidence for {len(llm_targets)} files")
            evidence_map: dict[str, dict[str, Any]] = {}
            for c in _wrap_iter(cfg, llm_targets, total=len(llm_targets), desc="evidence"):
                if not isinstance(c, dict):
                    continue
                p = str(c.get("path") or "")
                syms = _symbol_candidates_for_file(project, p)
                evidence_map[p] = _build_code_evidence(
                    root=root,
                    path=p,
                    symbols=syms,
                    max_symbols=int(cfg.llm_max_symbols_per_file),
                    max_excerpt_tokens=int(cfg.llm_max_excerpt_tokens),
                )

            max_workers = max(1, int(cfg.llm_concurrency))
            results: dict[str, dict[str, Any]] = {}

            _p(cfg, f"LLM refine: running {len(llm_targets)} file analyses (concurrency={max_workers})")
            with ThreadPoolExecutor(max_workers=max_workers) as ex:
                futs = {}
                for c in llm_targets:
                    if not isinstance(c, dict):
                        continue
                    p = str(c.get("path") or "")
                    ev = evidence_map.get(p, {})
                    futs[ex.submit(_llm_refine_file, query=query, file_candidate=c, evidence=ev)] = p
                done_iter = as_completed(futs)
                done_iter2 = _wrap_iter(cfg, done_iter, total=len(futs), desc="llm refine")
                for fut in done_iter2:
                    p = futs[fut]
                    try:
                        results[p] = fut.result()
                    except Exception as e:
                        results[p] = {"error": f"LLMError: {e}", "raw": None}

            # Attach llm_matches into candidates (and optionally replace interfaces ordering).
            for c in candidates:
                if not isinstance(c, dict):
                    continue
                p = str(c.get("path") or "")
                r = results.get(p)
                if not isinstance(r, dict):
                    continue
                c["llm"] = r
                ms = r.get("matches", [])
                if isinstance(ms, list) and ms:
                    # Use LLM matches as the primary interface list, keep heuristic as fallback.
                    c["interfaces_llm"] = ms[:6]

        except Exception as e:
            out["llm_error"] = str(e)

    _p(cfg, "done")
    out["markdown"] = _render_markdown(out)  # for CLI convenience
    return out


def _render_markdown(result: dict[str, Any]) -> str:
    query = str(result.get("query") or "")
    meta = _safe_dict(result.get("meta"))
    cands = _safe_list(result.get("candidates"))

    lines: list[str] = []
    lines.append(f"## LocateAgent result")
    lines.append("")
    lines.append(f"- **Query**: {query}")
    lines.append(f"- **Generated at**: `{meta.get('generated_at','')}`")
    lines.append(f"- **Project map**: `{meta.get('project_map_path','')}`")
    if meta.get("architecture_json_path"):
        lines.append(f"- **Architecture json**: `{meta.get('architecture_json_path','')}`")
    qt = _safe_list(meta.get("query_tokens"))
    if qt:
        lines.append(f"- **Query tokens (debug)**: `{', '.join(str(x) for x in qt[:30])}`")
    lines.append("")

    lines.append("## Candidates")
    if not cands:
        lines.append("- (none)")
        lines.append("")
        return "\n".join(lines)

    for i, c in enumerate(cands, start=1):
        if not isinstance(c, dict):
            continue
        path = str(c.get("path") or "")
        mid = str(c.get("module_id") or "")
        score = c.get("score", 0)
        why = _safe_list(c.get("why"))
        lines.append(f"### {i}. `{path}`")
        lines.append(f"- **Score**: {score}")
        if mid:
            lines.append(f"- **Module**: `{mid}`")
        if why:
            lines.append(f"- **Why**: `{', '.join(str(x) for x in why[:12])}`")
        ae = str(c.get("analysis_error") or "")
        if ae:
            lines.append(f"- **Analysis error (FileAtlas)**: `{ae}`")

        # Interfaces / symbols
        llm = _safe_dict(c.get("llm"))
        llm_matches = _safe_list(c.get("interfaces_llm"))
        if llm_matches:
            lines.append("- **LLM refined matches (higher precision)**:")
            for m in llm_matches[:6]:
                if not isinstance(m, dict):
                    continue
                qn = str(m.get("qualname") or "")
                sig = str(m.get("signature") or "")
                sp = _safe_dict(m.get("span"))
                sl = sp.get("start_line", "?")
                el = sp.get("end_line", "?")
                conf = m.get("confidence", "")
                reason = str(m.get("reason") or "")
                lines.append(f"  - `{qn}` @ `{path}:L{sl}-L{el}` (confidence={conf})")
                if sig:
                    lines.append(f"    - signature: `{sig}`")
                if reason:
                    lines.append(f"    - reason: {reason}")
        elif llm.get("error"):
            lines.append(f"- **LLM refine error**: `{llm.get('error')}`")

        ifaces = _safe_list(c.get("interfaces"))
        if ifaces:
            lines.append("- **Possible interfaces / symbols**:")
            for it in ifaces[: max(1, len(ifaces))]:
                if not isinstance(it, dict):
                    continue
                qn = str(it.get("qualname") or it.get("name") or "")
                sig = str(it.get("signature") or "")
                sp = _safe_dict(it.get("span"))
                sl = sp.get("start_line", "?")
                el = sp.get("end_line", "?")
                lines.append(f"  - `{qn}` @ `{path}:L{sl}-L{el}`")
                if sig:
                    lines.append(f"    - signature: `{sig}`")
        else:
            lines.append("- **Possible interfaces / symbols**: (none extracted)")

        kis = _safe_list(c.get("key_interfaces"))
        if kis:
            lines.append("- **File key_interfaces (LLM, best-effort)**:")
            for ki in kis[:10]:
                lines.append(f"  - `{str(ki)}`")
        imps = _safe_list(c.get("imports"))
        if imps:
            lines.append("- **Top imports (hints)**:")
            for imp in imps[:10]:
                lines.append(f"  - `{str(imp)}`")
        lines.append("")

    return "\n".join(lines)

