package handler

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/gin-gonic/gin"

	"github.com/sundynix/sundynix-gateway/internal/blob"
	"github.com/sundynix/sundynix-gateway/internal/nats"
	"github.com/sundynix/sundynix-gateway/internal/store"
	"github.com/sundynix/sundynix-shared/contract"
)

// rawKB 规整知识库名（去空白，空则 default）—— 注册表里的展示名。
func rawKB(kb string) string {
	kb = strings.TrimSpace(kb)
	if kb == "" {
		return "default"
	}
	return kb
}

// scopedKB 把知识库名锁进当前用户作用域："owner/name"。
// owner 来自身份（X-User-ID），客户端只发库名、发不了 owner，故无法越权查到他人的库。
func scopedKB(c *gin.Context, kb string) string {
	return userID(c) + "/" + rawKB(kb)
}

// KbList: GET /api/v1/kb/list —— 当前用户的知识库列表（按 owner 隔离）。
func (h *Handler) KbList(c *gin.Context) {
	rows, err := h.db.ListKB(c.Request.Context(), userID(c))
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	out := make([]gin.H, 0, len(rows))
	for _, r := range rows {
		out = append(out, gin.H{"name": r.Name, "kind": r.Kind})
	}
	c.JSON(http.StatusOK, gin.H{"kbs": out})
}

// KbCreate: POST /api/v1/kb/create {name, kind} —— 新建知识库（folder/project/case/general）。
func (h *Handler) KbCreate(c *gin.Context) {
	var body struct {
		Name string `json:"name"`
		Kind string `json:"kind"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
		return
	}
	if err := h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.Name), body.Kind); err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"name": rawKB(body.Name), "kind": body.Kind})
}

// KbIngest: POST /api/v1/kb/ingest —— 文本入库（异步，返回 job_id；进度经 SSE 看）。
func (h *Handler) KbIngest(c *gin.Context) {
	var body struct {
		KB   string `json:"kb"`
		Text string `json:"text"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
		return
	}
	_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
	job, err := h.enqueueIngest(c.Request.Context(), userID(c), rawKB(body.KB), scopedKB(c, body.KB), "", "", nil, body.Text)
	if err != nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusAccepted, gin.H{"job_id": job})
}

// KbSaveNote: POST /api/v1/kb/note {kb, name, content} —— 新建/编辑笔记。
// 立即落库（文库可见），并以 name 为 doc 重新入库（替换旧块，搜索/图谱同步）。返回 job_id。
func (h *Handler) KbSaveNote(c *gin.Context) {
	var body struct {
		KB      string `json:"kb"`
		Name    string `json:"name"`
		Content string `json:"content"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Content) == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name/content required"})
		return
	}
	owner := userID(c)
	_ = h.db.EnsureKB(c.Request.Context(), owner, rawKB(body.KB), "general")
	// 落库 + 重建索引由入库工作队列统一处理（forceDoc=name 保持笔记身份）。
	job, err := h.enqueueIngest(c.Request.Context(), owner, rawKB(body.KB), scopedKB(c, body.KB), body.Name, "", nil, body.Content)
	if err != nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusAccepted, gin.H{"job_id": job, "name": body.Name})
}

// wikiLinks 从内容抽取所有 [[名称]]（忽略别名）去重，用于维护双链索引。
func wikiLinks(s string) []string {
	seen := map[string]bool{}
	var out []string
	for _, m := range wikiRe.FindAllStringSubmatch(s, -1) {
		n := strings.TrimSpace(m[1])
		if n != "" && !seen[n] {
			seen[n] = true
			out = append(out, n)
		}
	}
	return out
}

var wikiRe = regexp.MustCompile(`\[\[([^\]|]+)(\|[^\]]*)?\]\]`)

// KbVault: GET /api/v1/kb/vault?kb= —— 某知识库的全部原始文档（名+内容），供 Obsidian 式文库浏览。
func (h *Handler) KbVault(c *gin.Context) {
	rows, err := h.db.ListVault(c.Request.Context(), userID(c), rawKB(c.Query("kb")))
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	docs := make([]gin.H, 0, len(rows))
	for _, r := range rows {
		docs = append(docs, gin.H{"id": r.ID, "name": r.Name, "ext": r.Ext, "size": r.Size, "preview": r.Preview})
	}
	c.JSON(http.StatusOK, gin.H{"docs": docs})
}

// KbDoc: GET /api/v1/kb/doc?id= —— 按文件 ID 取单篇全文（按需加载，不在列表里拉全量）。
func (h *Handler) KbDoc(c *gin.Context) {
	d, err := h.db.GetDocByID(c.Request.Context(), userID(c), c.Query("id"))
	if err != nil || d == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "文档不存在"})
		return
	}
	content := d.Content
	if d.ObjectKey != "" && h.blob.Ready() { // 大文档：从 MinIO 取回正文
		if obj, oerr := h.blob.Get(c.Request.Context(), d.ObjectKey); oerr == nil {
			content = obj
		}
	}
	c.JSON(http.StatusOK, gin.H{"id": d.ID, "name": d.Name, "ext": d.Ext, "content": content, "size": d.Size})
}

// KbDeleteDoc: DELETE /api/v1/kb/doc?id= —— 级联删一份文档：
// 三库(向量/全文/图谱，经 mcp-go kb_delete 按 file_id) + MinIO 原文 + PG 元数据/双链。owner 作用域防越权。
func (h *Handler) KbDeleteDoc(c *gin.Context) {
	owner := userID(c)
	d, err := h.db.GetDocByID(c.Request.Context(), owner, c.Query("id"))
	if err != nil || d == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "文档不存在"})
		return
	}
	scoped := d.Owner + "/" + d.KB
	// 「类事务」删除：先删依赖存储（三库/MinIO），PG 记录**最后删**。任一存储删失败 → 不删 PG、
	// 保留记录报错让用户重试（三库/MinIO 删均幂等）；杜绝「PG 删了却在三库/MinIO 留下再也删不掉的孤儿」。
	// ① 三库按 file_id 级联删。失败 → 保留 PG 可重试。
	if res, e := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_delete"),
		&contract.ToolCall{Tool: "kb_delete", Args: map[string]any{"kb": scoped, "file_id": d.ID}}); e != nil || res == nil || !res.OK {
		msg := "kb_delete 无响应"
		if e != nil {
			msg = e.Error()
		} else if res != nil && res.Error != "" {
			msg = res.Error
		}
		log.Printf("[gateway] 三库删除失败，保留记录待重试 id=%s: %s", d.ID, msg)
		c.JSON(http.StatusBadGateway, gin.H{"error": "存储删除失败，未删除，请稍后重试：" + msg})
		return
	}
	// ② MinIO 原文。失败 → 同样保留 PG 可重试（三库已幂等清理，重试补删 MinIO+PG）。
	if d.ObjectKey != "" && h.blob.Ready() {
		if e := h.blob.Delete(c.Request.Context(), d.ObjectKey); e != nil {
			log.Printf("[gateway] MinIO 删除失败，保留记录待重试 id=%s: %v", d.ID, e)
			c.JSON(http.StatusBadGateway, gin.H{"error": "原文删除失败，未删除，请稍后重试"})
			return
		}
	}
	// ③ PG 元数据 + 双链（最后删；到此依赖存储已清，PG 删成即整体一致）。
	if e := h.db.DeleteDocByID(c.Request.Context(), owner, d.KB, d.ID); e != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": e.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"deleted": d.ID, "name": d.Name})
}

// KbLinks: GET /api/v1/kb/links?kb= —— 某库已解析的 [[双链]]（FromID→ToID），供反链/笔记关系图按 ID 渲染。
func (h *Handler) KbLinks(c *gin.Context) {
	rows, err := h.db.ListLinks(c.Request.Context(), userID(c), rawKB(c.Query("kb")))
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	links := make([]gin.H, 0, len(rows))
	for _, l := range rows {
		links = append(links, gin.H{"from": l.FromID, "to": l.ToID})
	}
	c.JSON(http.StatusOK, gin.H{"links": links})
}

// noteName 取文本首个非空行作笔记名（截断 40 字），用于文本入库的文库留存。
func noteName(text string) string {
	for _, line := range strings.Split(text, "\n") {
		line = strings.TrimSpace(line)
		if line != "" {
			r := []rune(line)
			if len(r) > 40 {
				return string(r[:40])
			}
			return line
		}
	}
	return "笔记"
}

// KbIngestFile: POST /api/v1/kb/ingest_file（multipart）—— 文件入库（异步，返回 job_id）。
// 流水线（解析→切块→向量化→写入）的进度经 sundynix.streams.<job_id> 回流，UI 用 SSE 看。
func (h *Handler) KbIngestFile(c *gin.Context) {
	kb := c.PostForm("kb")
	fh, err := c.FormFile("file")
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
		return
	}
	f, err := fh.Open()
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	defer f.Close()
	data, err := io.ReadAll(f)
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(kb), "general")
	job, err := h.enqueueIngest(c.Request.Context(), userID(c), rawKB(kb), scopedKB(c, kb), "", fh.Filename, data, "")
	if err != nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusAccepted, gin.H{"job_id": job, "file": fh.Filename})
}

// StartIngestWorkers 启动入库 worker 池：在 JetStream 入库队列上有界并发消费作业（背压、崩溃重投）。
// 返回 drain 供优雅停机时等在途入库跑完（超时未完的由 AckWait 在重启后重投兜底）。
func StartIngestWorkers(ctx context.Context, db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.Store) (func(context.Context), error) {
	h := New(db, cache, bus, blobStore)
	return bus.ConsumeIngestJobs(ctx, h.processIngestJob)
}

// enqueueIngest 把一次入库请求暂存到对象存储(claim-check)，再发布作业到 JetStream 工作队列，
// 立即返回 job_id。暂存让作业消息恒小（不受 max_payload 限制）、崩溃重投只搬引用。
// MinIO 是准生产硬依赖（正文也一律落 MinIO）；未就绪即拒绝入库，不静默退化。
func (h *Handler) enqueueIngest(ctx context.Context, owner, kbName, scoped, forceDoc, filename string, data []byte, rawText string) (string, error) {
	if !h.blob.Ready() {
		return "", errors.New("对象存储未就绪，暂时无法入库")
	}
	job := newJobID()
	stageKey := "ingest-staging/" + job
	isText := filename == ""
	payload := rawText
	if !isText {
		payload = string(data) // 原始文件字节（string 可承载任意字节）
	}
	if err := h.blob.Put(ctx, stageKey, payload); err != nil {
		return "", fmt.Errorf("暂存失败: %w", err)
	}
	jobMsg := &contract.IngestJob{
		JobID: job, Owner: owner, KBName: kbName, Scoped: scoped,
		ForceDoc: forceDoc, Filename: filename, StageKey: stageKey, IsText: isText,
	}
	if err := h.bus.PublishIngestJob(ctx, jobMsg); err != nil {
		h.blob.Delete(ctx, stageKey) // 入队失败 → 清暂存避免泄漏
		return "", fmt.Errorf("入队失败: %w", err)
	}
	return job, nil
}

// processIngestJob 是入库 worker：从暂存取回原料 → 跑入库流水线 → 成功/彻底失败后清暂存。
// 返回非 nil → 队列延迟重投（仅瞬时基建失败且未到最后一次投递时）。幂等：按 doc 先删后写，重跑安全。
func (h *Handler) processIngestJob(ctx context.Context, job *contract.IngestJob, lastAttempt bool) error {
	payload, err := h.blob.Get(ctx, job.StageKey)
	if err != nil {
		// 暂存读不到（已清/对象丢失）——无法恢复，别无限重试，直接终态收尾。
		_ = h.bus.PublishIngest(job.JobID, &contract.IngestEvent{Stage: "失败", Error: "暂存读取失败: " + err.Error()})
		_ = h.bus.CompleteStream(job.JobID)
		return nil
	}
	var data []byte
	var rawText string
	if job.IsText {
		rawText = payload
	} else {
		data = []byte(payload)
	}
	retryable, rerr := h.runIngest(ctx, job.JobID, job.Owner, job.KBName, job.Scoped, job.ForceDoc, job.Filename, data, rawText)
	if rerr != nil && retryable && !lastAttempt {
		return rerr // 瞬时失败 → 保留暂存，延迟重投
	}
	h.blob.Delete(context.Background(), job.StageKey) // 成功或彻底失败 → 清暂存
	return nil
}

// runIngest 跑入库流水线，逐阶段把进度发到 sundynix.streams.<job>，由入库 worker 调用。
// owner+kbName 用于"文库"原文留存；scoped 是 owner/kb 作向量/全文/图谱分区键。
// forceDoc 非空时强制以它为文档名（笔记编辑用，保持笔记身份稳定）。
// filename 非空表示文件入库（先经 mcp-py 解析）；否则用 rawText。
// 返回 (retryable, err)：解析失败=终态(坏输入重试无益)；kb_ingest 基建失败=瞬时可重试。
func (h *Handler) runIngest(ctx context.Context, job, owner, kbName, scoped, forceDoc, filename string, data []byte, rawText string) (retryable bool, err error) {
	emit := func(ev contract.IngestEvent) { _ = h.bus.PublishIngest(job, &ev) }
	time.Sleep(400 * time.Millisecond) // 给 SSE 客户端订阅时间（core NATS 无缓冲）

	text := rawText
	if filename != "" {
		emit(contract.IngestEvent{Stage: "解析", Msg: filename})
		parsed, perr := h.parseFile(ctx, filename, data)
		if perr != nil {
			emit(contract.IngestEvent{Stage: "失败", Error: "解析失败: " + perr.Error()})
			_ = h.bus.CompleteStream(job)
			return false, fmt.Errorf("解析失败: %w", perr) // 终态：坏输入
		}
		emit(contract.IngestEvent{
			Stage:   "解析完成",
			Msg:     fmt.Sprintf("%s · 解析出 %d 字", fileKind(filename), len([]rune(parsed))),
			Preview: head(parsed, 240),
		})
		text = parsed
	}

	// 文库留存原文：编辑指定名 > 文件名 > 文本首行。
	docName := forceDoc
	if docName == "" {
		docName = filename
	}
	if docName == "" {
		docName = noteName(text)
	}
	var fileID string // 文档稳定 ID（雪花），作 Milvus/Bleve/Neo4j 的关联键——重名/重入库不变，删可级联
	if text != "" {
		size := len([]rune(text))
		ext := strings.ToLower(filepath.Ext(filename)) // 笔记/文本入库时 filename 为空 → ext 为空
		inline, objectKey := text, ""
		// 正文一律落对象存储（MinIO），PG 只留元数据+预览+对象键（不分大小，不把正文塞进 PG）。
		// 仅当 MinIO 不可用或写失败时，才回退内联，保证正文不丢。
		if h.blob.Ready() {
			key := owner + "/" + kbName + "/" + docName
			if err := h.blob.Put(ctx, key, text); err == nil {
				inline, objectKey = "", key
			} else {
				log.Printf("[gateway] 正文转 MinIO 失败，回退内联: %v", err)
			}
		} else {
			log.Printf("[gateway] MinIO 未就绪，正文回退内联存 PG（doc=%s）", docName)
		}
		docID, oldKey, err := h.db.SaveDoc(ctx, owner, kbName, docName, ext, inline, objectKey, size, head(text, 500))
		if err != nil {
			log.Printf("[gateway] 文件入库失败: %v", err)
		} else if docID != "" {
			fileID = docID // 下游三库用它作关联键
			// 孤儿 GC：重名覆盖后旧对象键若已不用（转内联或换键），从 MinIO 删除，避免泄漏。
			if oldKey != "" && oldKey != objectKey && h.blob.Ready() {
				h.blob.Delete(ctx, oldKey)
				log.Printf("[gateway] 清理被覆盖的 MinIO 孤儿对象: %s", oldKey)
			}
			_ = h.db.ReplaceDocLinks(ctx, owner, kbName, docID, wikiLinks(text)) // 以本文件 ID 维护出链
			_ = h.db.ResolveInboundLinks(ctx, owner, kbName, docName, docID)      // 回填指向本文件的悬空链接
		}
	}

	// 调 mcp-go kb_ingest：doc 传**稳定 file_id**（非展示名），作下游三库关联键，使重名/重入库幂等、删可级联。
	// SaveDoc 失败兜底退回用名字做键（仍能入库，只是失去 file_id 稳定性）。
	docKey := fileID
	if docKey == "" {
		docKey = docName
	}
	res, cerr := h.bus.CallTool(ctx, contract.ToolSubjectGo("kb_ingest"),
		&contract.ToolCall{Tool: "kb_ingest", Args: map[string]any{"kb": scoped, "doc": docKey, "text": text, "job_id": job}})
	if cerr != nil || res == nil || !res.OK {
		msg := "kb_ingest 失败"
		if cerr != nil {
			msg = cerr.Error()
		} else if res != nil {
			msg = res.Error
		}
		emit(contract.IngestEvent{Stage: "失败", Error: msg})
		_ = h.bus.CompleteStream(job)
		return true, errors.New(msg) // 瞬时：mcp-go/embedding/Milvus 抖动，可重试
	}
	return false, nil
}

// KbIngestStream: GET /api/v1/kb/ingest/:id/stream —— SSE 实时推送入库进度事件。
func (h *Handler) KbIngestStream(c *gin.Context) {
	job := c.Param("id")
	c.Writer.Header().Set("Content-Type", "text/event-stream")
	c.Writer.Header().Set("Cache-Control", "no-cache")
	c.Writer.Header().Set("Connection", "keep-alive")

	events := make(chan []byte, 64)
	done := make(chan struct{})
	unsub, err := h.bus.SubscribeTokens(job,
		func(ev []byte) {
			select {
			case events <- ev:
			default:
			}
		},
		func() { close(done) },
	)
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	defer func() { _ = unsub() }()

	c.Stream(func(w io.Writer) bool {
		select {
		case ev := <-events:
			c.SSEvent("progress", string(ev))
			return true
		case <-done:
			c.SSEvent("done", job)
			return false
		case <-c.Request.Context().Done():
			return false
		}
	})
}

func newJobID() string {
	var b [8]byte
	_, _ = rand.Read(b[:])
	return "ingest_" + hex.EncodeToString(b[:])
}

// itoa 简易整数转字符串（避免引入 strconv）。
func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	neg := n < 0
	if neg {
		n = -n
	}
	var b []byte
	for n > 0 {
		b = append([]byte{byte('0' + n%10)}, b...)
		n /= 10
	}
	if neg {
		b = append([]byte{'-'}, b...)
	}
	return string(b)
}

// head 取文本前 n 个字符（按 rune），用于解析预览。
func head(s string, n int) string {
	s = strings.TrimSpace(s)
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n]) + "…"
}

// fileKind 由扩展名给出可读的文件类型标签。
func fileKind(filename string) string {
	switch strings.ToLower(filepath.Ext(filename)) {
	case ".docx":
		return "Word 文档"
	case ".xlsx", ".xls":
		return "Excel 表格"
	case ".pdf":
		return "PDF"
	case ".csv":
		return "CSV"
	case ".md", ".markdown":
		return "Markdown"
	default:
		return "文本"
	}
}

// parseFile 把文件字节转为纯文本：文本类直读，其余经 mcp-py parse_document（算法层）。
func (h *Handler) parseFile(ctx context.Context, filename string, data []byte) (string, error) {
	switch strings.ToLower(filepath.Ext(filename)) {
	case ".txt", ".md", ".markdown", ".text":
		return string(data), nil
	}
	res, err := h.bus.CallTool(ctx, contract.ToolSubjectPy("parse_document"),
		&contract.ToolCall{Tool: "parse_document", Args: map[string]any{
			"filename": filename, "content_b64": base64.StdEncoding.EncodeToString(data),
		}})
	if err != nil {
		return "", err
	}
	if res == nil || !res.OK {
		if res != nil && res.Error != "" {
			return "", errors.New(res.Error)
		}
		return "", errors.New("parse_document 无响应（mcp-py 未运行？）")
	}
	return res.Content, nil
}

// KbSearch: POST /api/v1/kb/search —— 检索台：查某知识库，返回带分数的命中（→ mcp-go kb_search）。
func (h *Handler) KbSearch(c *gin.Context) {
	var body struct {
		KB   string `json:"kb"`
		Q    string `json:"q"`
		TopK int    `json:"topK"`
		Mode string `json:"mode"` // 空=生产混合(含rerank)；vector/fulltext/graph/hybrid=评测单路/纯融合
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Q == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "q required"})
		return
	}
	args := map[string]any{"kb": scopedKB(c, body.KB), "q": body.Q}
	if body.TopK > 0 {
		args["topK"] = body.TopK
	}
	if body.Mode != "" {
		args["mode"] = body.Mode
	}
	res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_search"),
		&contract.ToolCall{Tool: "kb_search", Args: args})
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	if !res.OK {
		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
		return
	}
	var hits []map[string]any
	_ = json.Unmarshal([]byte(res.Content), &hits)
	c.JSON(http.StatusOK, gin.H{"hits": hits})
}

// KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组（→ mcp-go kb_graph，Neo4j）。
func (h *Handler) KbGraph(c *gin.Context) {
	res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_graph"),
		&contract.ToolCall{Tool: "kb_graph", Args: map[string]any{"kb": scopedKB(c, c.Query("kb")), "limit": 100}})
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	var triples []map[string]any
	_ = json.Unmarshal([]byte(res.Content), &triples)
	c.JSON(http.StatusOK, gin.H{"triples": triples})
}
