package com.gzzm.lobster.tool.builtin;

import com.gzzm.lobster.audit.AuditService;
import com.gzzm.lobster.common.ToolCategory;
import com.gzzm.lobster.common.ToolRiskLevel;
import com.gzzm.lobster.oa.OaKnowledgeClient;
import com.gzzm.lobster.oa.OaKnowledgeHit;
import com.gzzm.lobster.tool.BuiltinToolDefinition;
import com.gzzm.lobster.tool.SchemaBuilder;
import com.gzzm.lobster.tool.ToolContext;
import com.gzzm.lobster.tool.ToolRegistry;
import com.gzzm.lobster.tool.ToolResult;
import net.cyan.nest.annotation.Inject;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Built-in OA knowledge-base tools.
 *
 * <p>scopeIds selected in the UI are passed through ToolContext attributes
 * with key {@code kb.scopeIds}. The schema intentionally does not expose
 * scopeIds to the LLM.
 */
public class OaKnowledgeTools {

    @Inject private OaKnowledgeClient oaKnowledgeClient;
    @Inject private AuditService auditService;

    public void registerTo(ToolRegistry registry) {
        registry.register(searchDef(), this::search);
        registry.register(detailDef(), this::detail);
    }

    private BuiltinToolDefinition searchDef() {
        return BuiltinToolDefinition.builder()
                .name("oa_search_knowledge")
                .displayName("Search OA KB")
                .description("Search the OA knowledge base by keyword. Scope and permissions are injected by the system.")
                .category(ToolCategory.OA)
                .risk(ToolRiskLevel.READ_ONLY)
                .inputSchema(SchemaBuilder.obj()
                        .prop("query", "string", "Search query")
                        .propInt("maxResults", "Number of hits to return, 1-20. Default is 10.")
                        .required("query")
                        .build())
                .build();
    }

    @SuppressWarnings("unchecked")
    private ToolResult search(ToolContext ctx, Map<String, Object> args) throws Exception {
        String query = asStr(args.get("query"));
        if (query == null || query.trim().isEmpty()) {
            return ToolResult.error("query is required");
        }
        query = query.trim();
        int maxResults = clamp(asInt(args.get("maxResults"), 10), 1, 20);

        List<String> scopeIds = null;
        Object fromAttr = ctx.getAttribute("kb.scopeIds");
        if (fromAttr instanceof List) scopeIds = asStringList(fromAttr);
        if ((scopeIds == null || scopeIds.isEmpty()) && args.containsKey("scopeIds")) {
            scopeIds = asStringList(args.get("scopeIds"));
        }

        List<OaKnowledgeHit> hits = oaKnowledgeClient.search(ctx.getUserContext(), query, scopeIds, maxResults);
        auditService.record(ctx.getUserContext(), ctx.getThreadId(), ctx.getRunId(),
                "oa.kb.search", "oa_kb", query, "ok", null);

        List<Map<String, Object>> out = new ArrayList<>();
        for (OaKnowledgeHit h : hits) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("docId", h.getDocId());
            row.put("title", h.getTitle());
            row.put("snippet", h.getSnippet());
            row.put("score", h.getScore());
            row.put("source", h.getSource());
            if (h.getScopeName() != null) row.put("scopeName", h.getScopeName());
            if (h.getUrl() != null) row.put("url", h.getUrl());
            if (h.getUpdatedAt() != null) row.put("updatedAt", h.getUpdatedAt());
            out.add(row);
        }
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("hits", out);
        if (scopeIds != null && !scopeIds.isEmpty()) data.put("scopeIds", scopeIds);

        String msg = "Hit " + out.size()
                + (scopeIds != null && !scopeIds.isEmpty() ? " scoped to " + String.join(",", scopeIds) : "");
        return ToolResult.ok(msg, data);
    }

    private BuiltinToolDefinition detailDef() {
        return BuiltinToolDefinition.builder()
                .name("oa_get_knowledge_detail")
                .displayName("Get OA KB detail")
                .description("Read OA KB document content in pages. Use nextOffset when hasMore=true; pass query or sectionHint to locate relevant chunks.")
                .category(ToolCategory.OA)
                .risk(ToolRiskLevel.READ_ONLY)
                .inputSchema(SchemaBuilder.obj()
                        .prop("docId", "string", "Document ID")
                        .propInt("offset", "Chunk offset, default 0. Use nextOffset from the previous response to continue.")
                        .propInt("chunkCharOffset", "Character offset inside the current chunk. Use nextChunkCharOffset when it is returned.")
                        .propInt("limit", "Maximum chunks to return in this call, default 8, max 50.")
                        .propInt("maxChars", "Maximum content characters for this call, default 12000, max 50000.")
                        .prop("query", "string", "Optional question text used to locate relevant chunks on the first read.")
                        .prop("sectionHint", "string", "Optional section title or keyword used to locate relevant chunks on the first read.")
                        .required("docId")
                        .build())
                .build();
    }

    private ToolResult detail(ToolContext ctx, Map<String, Object> args) throws Exception {
        String docId = asStr(args.get("docId"));
        if (docId == null || docId.trim().isEmpty()) {
            return ToolResult.error("docId is required");
        }
        int offset = Math.max(0, asInt(args.get("offset"), 0));
        int chunkCharOffset = Math.max(0, asInt(args.get("chunkCharOffset"), 0));
        int limit = clamp(asInt(args.get("limit"), 8), 1, 50);
        int maxChars = clamp(asInt(args.get("maxChars"), 12000), 1000, 50000);
        String query = asStr(args.get("query"));
        String sectionHint = asStr(args.get("sectionHint"));

        Map<String, Object> data = oaKnowledgeClient.getDetailPage(
                ctx.getUserContext(), docId.trim(), offset, limit, maxChars, query, sectionHint, chunkCharOffset);
        auditService.record(ctx.getUserContext(), ctx.getThreadId(), ctx.getRunId(),
                "oa.kb.detail", "oa_kb_doc", docId.trim(), "ok", null);

        boolean hasMore = Boolean.TRUE.equals(data.get("hasMore"));
        String msg = "Read KB content"
                + " offset=" + String.valueOf(data.get("offset"))
                + " returnedChunks=" + String.valueOf(data.get("returnedChunks"))
                + (hasMore ? continueHint(data) : "; end reached");
        return ToolResult.ok(msg, data);
    }

    private String asStr(Object o) { return o == null ? null : String.valueOf(o); }

    private String continueHint(Map<String, Object> data) {
        Object nextChar = data.get("nextChunkCharOffset");
        return "; continue with nextOffset=" + String.valueOf(data.get("nextOffset"))
                + (nextChar == null ? "" : ", chunkCharOffset=" + String.valueOf(nextChar));
    }

    private int asInt(Object o, int def) {
        if (o == null) return def;
        if (o instanceof Number) return ((Number) o).intValue();
        try { return Integer.parseInt(String.valueOf(o)); } catch (Exception e) { return def; }
    }

    @SuppressWarnings("unchecked")
    private List<String> asStringList(Object o) {
        if (o == null) return null;
        if (o instanceof List) {
            List<String> out = new ArrayList<>();
            for (Object x : (List<Object>) o) {
                if (x == null) continue;
                String s = String.valueOf(x).trim();
                if (!s.isEmpty()) out.add(s);
            }
            return out;
        }
        if (o instanceof String) {
            String s = ((String) o).trim();
            if (s.isEmpty()) return null;
            return Collections.singletonList(s);
        }
        return null;
    }

    private int clamp(int v, int min, int max) { return Math.max(min, Math.min(max, v)); }
}
