package handlers

import (
	"context"
	"fmt"
	"io"
	"log"
	"net/http"
	"path/filepath"
	"strings"

	"github.com/gin-gonic/gin"

	"chat-switchboard/knowledge"
	"chat-switchboard/models"
	"chat-switchboard/storage"
	"chat-switchboard/store"
)

// ── Limits ───────────────────────────────────

const (
	kbMaxDocSize = 10 * 1024 * 1024 // 10 MB per document
	kbMaxNameLen = 200
	kbMaxDescLen = 2000
)

// allowedKBMIMETypes lists content types accepted for KB documents.
var allowedKBMIMETypes = map[string]bool{
	"text/plain":    true,
	"text/markdown": true,
	"text/csv":      true,
	"text/html":     true,
	// Future: PDF, docx once extraction sidecar is wired in
}

// ── Request / Response Types ─────────────────

type createKBRequest struct {
	Name        string `json:"name" binding:"required"`
	Description string `json:"description"`
	Scope       string `json:"scope"` // personal (default), team, global
	TeamID      string `json:"team_id"`
}

type updateKBRequest struct {
	Name        *string `json:"name"`
	Description *string `json:"description"`
}

type kbResponse struct {
	ID              string          `json:"id"`
	Name            string          `json:"name"`
	Description     string          `json:"description"`
	Scope           string          `json:"scope"`
	OwnerID         *string         `json:"owner_id,omitempty"`
	TeamID          *string         `json:"team_id,omitempty"`
	EmbeddingConfig models.JSONMap  `json:"embedding_config"`
	DocumentCount   int             `json:"document_count"`
	ChunkCount      int             `json:"chunk_count"`
	TotalBytes      int64           `json:"total_bytes"`
	Status          string          `json:"status"`
	CreatedAt       string          `json:"created_at"`
	UpdatedAt       string          `json:"updated_at"`
}

type docResponse struct {
	ID          string  `json:"id"`
	KBID        string  `json:"kb_id"`
	Filename    string  `json:"filename"`
	ContentType string  `json:"content_type"`
	SizeBytes   int64   `json:"size_bytes"`
	ChunkCount  int     `json:"chunk_count"`
	Status      string  `json:"status"`
	Error       *string `json:"error,omitempty"`
	UploadedBy  string  `json:"uploaded_by"`
	CreatedAt   string  `json:"created_at"`
	UpdatedAt   string  `json:"updated_at"`
}

type searchKBRequest struct {
	Query     string  `json:"query" binding:"required"`
	Limit     int     `json:"limit"`
	Threshold float64 `json:"threshold"`
}

// ── Handler ──────────────────────────────────

// KnowledgeBaseHandler handles KB CRUD and document management.
type KnowledgeBaseHandler struct {
	stores   store.Stores
	objStore storage.ObjectStore
	ingester *knowledge.Ingester
	embedder *knowledge.Embedder
}

// NewKnowledgeBaseHandler creates a KB handler.
func NewKnowledgeBaseHandler(
	stores store.Stores,
	objStore storage.ObjectStore,
	ingester *knowledge.Ingester,
	embedder *knowledge.Embedder,
) *KnowledgeBaseHandler {
	return &KnowledgeBaseHandler{
		stores:   stores,
		objStore: objStore,
		ingester: ingester,
		embedder: embedder,
	}
}

// ── KB CRUD ──────────────────────────────────

// CreateKB creates a new knowledge base.
// POST /api/v1/knowledge-bases
func (h *KnowledgeBaseHandler) CreateKB(c *gin.Context) {
	var req createKBRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if len(req.Name) > kbMaxNameLen {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"})
		return
	}
	if len(req.Description) > kbMaxDescLen {
		c.JSON(http.StatusBadRequest, gin.H{"error": "description too long"})
		return
	}

	userID := getUserID(c)

	// Default scope
	scope := "personal"
	if req.Scope != "" {
		scope = req.Scope
	}

	// Validate scope
	switch scope {
	case "personal":
		// OK — owned by user
	case "team":
		if req.TeamID == "" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team scope"})
			return
		}
		// Verify user is team admin (or system admin)
		role, _ := c.Get("role")
		if role != "admin" {
			isTA, err := h.stores.Teams.IsTeamAdmin(c.Request.Context(), req.TeamID, userID)
			if err != nil || !isTA {
				c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required to create team knowledge bases"})
				return
			}
		}
	case "global":
		// Verify user is system admin
		role, _ := c.Get("role")
		if role != "admin" {
			c.JSON(http.StatusForbidden, gin.H{"error": "admin access required to create global knowledge bases"})
			return
		}
	default:
		c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be personal, team, or global"})
		return
	}

	kb := &models.KnowledgeBase{
		Name:            req.Name,
		Description:     req.Description,
		Scope:           scope,
		EmbeddingConfig: models.JSONMap{},
		Status:          "active",
	}

	// Set ownership based on scope
	switch scope {
	case "personal":
		kb.OwnerID = &userID
	case "team":
		kb.TeamID = &req.TeamID
	}

	if err := h.stores.KnowledgeBases.Create(c.Request.Context(), kb); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create knowledge base"})
		return
	}

	h.auditLog(c, "kb.create", kb.ID, map[string]interface{}{
		"name": kb.Name, "scope": kb.Scope,
	})

	c.JSON(http.StatusCreated, toKBResponse(kb))
}

// ListKBs lists knowledge bases accessible to the current user.
// GET /api/v1/knowledge-bases
func (h *KnowledgeBaseHandler) ListKBs(c *gin.Context) {
	userID := getUserID(c)

	// Get user's team IDs for scoped listing
	teamIDs := h.getUserTeamIDs(c, userID)

	kbs, err := h.stores.KnowledgeBases.ListForUser(c.Request.Context(), userID, teamIDs)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"})
		return
	}

	items := make([]kbResponse, len(kbs))
	for i := range kbs {
		items[i] = toKBResponse(&kbs[i])
	}

	c.JSON(http.StatusOK, gin.H{"data": items, "total": len(items)})
}

// GetKB returns a single knowledge base.
// GET /api/v1/knowledge-bases/:id
func (h *KnowledgeBaseHandler) GetKB(c *gin.Context) {
	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}
	c.JSON(http.StatusOK, toKBResponse(kb))
}

// UpdateKB updates a knowledge base's name/description.
// PUT /api/v1/knowledge-bases/:id
func (h *KnowledgeBaseHandler) UpdateKB(c *gin.Context) {
	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	var req updateKBRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	fields := make(map[string]interface{})
	if req.Name != nil {
		if len(*req.Name) > kbMaxNameLen {
			c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"})
			return
		}
		fields["name"] = *req.Name
	}
	if req.Description != nil {
		if len(*req.Description) > kbMaxDescLen {
			c.JSON(http.StatusBadRequest, gin.H{"error": "description too long"})
			return
		}
		fields["description"] = *req.Description
	}

	if len(fields) == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
		return
	}

	if err := h.stores.KnowledgeBases.Update(c.Request.Context(), kb.ID, fields); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update knowledge base"})
		return
	}

	// Reload for response
	updated, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kb.ID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload knowledge base"})
		return
	}
	c.JSON(http.StatusOK, toKBResponse(updated))
}

// DeleteKB deletes a knowledge base and all its documents/chunks.
// DELETE /api/v1/knowledge-bases/:id
func (h *KnowledgeBaseHandler) DeleteKB(c *gin.Context) {
	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	// Delete stored files
	if h.objStore != nil {
		prefix := fmt.Sprintf("kb/%s/", kb.ID)
		_ = h.objStore.DeletePrefix(c.Request.Context(), prefix)
	}

	// Delete from DB (cascades to documents and chunks)
	if err := h.stores.KnowledgeBases.Delete(c.Request.Context(), kb.ID); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete knowledge base"})
		return
	}

	h.auditLog(c, "kb.delete", kb.ID, map[string]interface{}{
		"name": kb.Name, "scope": kb.Scope,
	})

	c.JSON(http.StatusOK, gin.H{"deleted": true})
}

// ── Document Management ──────────────────────

// UploadDocument uploads a document to a KB and starts async ingestion.
// POST /api/v1/knowledge-bases/:id/documents
func (h *KnowledgeBaseHandler) UploadDocument(c *gin.Context) {
	if h.objStore == nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
		return
	}

	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	// Verify embedding role is configured
	if !h.embedder.IsConfigured(c.Request.Context()) {
		c.JSON(http.StatusPreconditionFailed, gin.H{
			"error": "embedding model role not configured — set up an embedding provider in Settings → Model Roles",
		})
		return
	}

	// Parse multipart
	file, header, err := c.Request.FormFile("file")
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
		return
	}
	defer file.Close()

	if header.Size > kbMaxDocSize {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("file too large (max %d MB)", kbMaxDocSize/(1024*1024)),
		})
		return
	}

	// MIME detection
	buf := make([]byte, 512)
	n, _ := file.Read(buf)
	contentType := http.DetectContentType(buf[:n])
	if idx := strings.Index(contentType, ";"); idx > 0 {
		contentType = strings.TrimSpace(contentType[:idx])
	}

	// Fall back to extension for octet-stream
	if contentType == "application/octet-stream" {
		ext := strings.ToLower(filepath.Ext(header.Filename))
		switch ext {
		case ".md", ".markdown":
			contentType = "text/markdown"
		case ".csv":
			contentType = "text/csv"
		case ".html", ".htm":
			contentType = "text/html"
		case ".txt":
			contentType = "text/plain"
		}
	}

	// Allowlist check
	if !allowedKBMIMETypes[contentType] {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("file type %q not supported for knowledge bases — only text, markdown, CSV, and HTML are currently supported", contentType),
		})
		return
	}

	// Reset reader
	if _, err := file.Seek(0, io.SeekStart); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"})
		return
	}

	userID := getUserID(c)
	filename := sanitizeFilename(header.Filename)

	// Create document record (status=pending)
	doc := &models.KBDocument{
		KBID:        kb.ID,
		Filename:    filename,
		ContentType: contentType,
		SizeBytes:   header.Size,
		StorageKey:  "placeholder",
		Status:      "pending",
		UploadedBy:  userID,
	}

	if err := h.stores.KnowledgeBases.CreateDocument(c.Request.Context(), doc); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create document record"})
		return
	}

	// Build storage key and save file
	doc.StorageKey = fmt.Sprintf("kb/%s/%s_%s", kb.ID, doc.ID, filename)
	if err := h.objStore.Put(c.Request.Context(), doc.StorageKey, file, header.Size, contentType); err != nil {
		// Rollback DB record
		h.stores.KnowledgeBases.DeleteDocument(c.Request.Context(), doc.ID)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
		return
	}

	// Update storage key in DB
	h.updateDocStorageKey(c, doc.ID, doc.StorageKey)

	// Resolve team ID for embedding role
	var teamID *string
	if kb.TeamID != nil {
		teamID = kb.TeamID
	}

	// Fire async ingestion
	h.ingester.IngestDocument(kb, doc, userID, teamID)

	h.auditLog(c, "kb.document.upload", kb.ID, map[string]interface{}{
		"kb_name": kb.Name, "doc_id": doc.ID, "filename": doc.Filename,
		"size_bytes": doc.SizeBytes,
	})

	c.JSON(http.StatusAccepted, toDocResponse(doc))
}

// ListDocuments lists all documents in a KB.
// GET /api/v1/knowledge-bases/:id/documents
func (h *KnowledgeBaseHandler) ListDocuments(c *gin.Context) {
	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	docs, err := h.stores.KnowledgeBases.ListDocuments(c.Request.Context(), kb.ID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list documents"})
		return
	}

	items := make([]docResponse, len(docs))
	for i := range docs {
		items[i] = toDocResponse(&docs[i])
	}

	c.JSON(http.StatusOK, gin.H{"data": items, "total": len(items)})
}

// GetDocumentStatus returns the current ingestion status of a document.
// GET /api/v1/knowledge-bases/:id/documents/:docId/status
func (h *KnowledgeBaseHandler) GetDocumentStatus(c *gin.Context) {
	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	docID := c.Param("docId")
	doc, err := h.stores.KnowledgeBases.GetDocument(c.Request.Context(), docID)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
		return
	}

	if doc.KBID != kb.ID {
		c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"id":          doc.ID,
		"status":      doc.Status,
		"chunk_count": doc.ChunkCount,
		"error":       doc.Error,
		"filename":    doc.Filename,
	})
}

// DeleteDocument removes a document and its chunks.
// DELETE /api/v1/knowledge-bases/:id/documents/:docId
func (h *KnowledgeBaseHandler) DeleteDocument(c *gin.Context) {
	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	docID := c.Param("docId")
	doc, err := h.stores.KnowledgeBases.GetDocument(c.Request.Context(), docID)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
		return
	}

	if doc.KBID != kb.ID {
		c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
		return
	}

	// Delete chunks first
	h.stores.KnowledgeBases.DeleteChunksForDocument(c.Request.Context(), docID)

	// Delete the document record (returns doc for storage cleanup)
	deleted, err := h.stores.KnowledgeBases.DeleteDocument(c.Request.Context(), docID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete document"})
		return
	}

	// Clean up stored file
	if h.objStore != nil && deleted != nil && deleted.StorageKey != "" && deleted.StorageKey != "placeholder" {
		_ = h.objStore.Delete(c.Request.Context(), deleted.StorageKey)
	}

	// Update KB stats
	h.stores.KnowledgeBases.UpdateStats(c.Request.Context(), kb.ID)

	h.auditLog(c, "kb.document.delete", kb.ID, map[string]interface{}{
		"kb_name": kb.Name, "doc_id": docID,
		"filename": safeFilename(deleted),
	})

	c.JSON(http.StatusOK, gin.H{"deleted": true})
}

// ── Search ───────────────────────────────────

// SearchKB performs a direct similarity search against a KB.
// POST /api/v1/knowledge-bases/:id/search
func (h *KnowledgeBaseHandler) SearchKB(c *gin.Context) {
	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	var req searchKBRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if req.Limit <= 0 || req.Limit > 20 {
		req.Limit = 5
	}
	if req.Threshold <= 0 {
		req.Threshold = 0.3 // default cosine similarity threshold
	}

	userID := getUserID(c)
	var teamID *string
	if kb.TeamID != nil {
		teamID = kb.TeamID
	}

	// Embed the query
	embedResult, err := h.embedder.EmbedChunks(c.Request.Context(), userID, teamID, []string{req.Query})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to embed query"})
		return
	}

	if len(embedResult.Vectors) == 0 {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "embedding produced no vectors"})
		return
	}

	// Search
	results, err := h.stores.KnowledgeBases.SimilaritySearch(
		c.Request.Context(),
		[]string{kb.ID},
		embedResult.Vectors[0],
		req.Threshold,
		req.Limit,
	)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"data":  results,
		"query": req.Query,
		"total": len(results),
	})
}

// ── Helpers ──────────────────────────────────

// loadAndAuthorize loads a KB by ID from the URL and checks the user
// has access to it (owner, team member, or admin for global).
func (h *KnowledgeBaseHandler) loadAndAuthorize(c *gin.Context) (*models.KnowledgeBase, bool) {
	kbID := c.Param("id")
	if kbID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing knowledge base ID"})
		return nil, false
	}

	kb, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kbID)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"})
		return nil, false
	}

	userID := getUserID(c)

	switch kb.Scope {
	case "personal":
		if kb.OwnerID == nil || *kb.OwnerID != userID {
			c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"})
			return nil, false
		}
	case "team":
		// Verify user is a member of the team
		teamIDs := h.getUserTeamIDs(c, userID)
		found := false
		for _, tid := range teamIDs {
			if kb.TeamID != nil && tid == *kb.TeamID {
				found = true
				break
			}
		}
		if !found {
			c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"})
			return nil, false
		}
	case "global":
		// Global KBs are readable by everyone, writable by admins
		// For now, allow all authenticated users to read
	}

	return kb, true
}

// getUserTeamIDs returns the team IDs for the current user.
func (h *KnowledgeBaseHandler) getUserTeamIDs(c *gin.Context, userID string) []string {
	ids, err := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
	if err != nil {
		return nil
	}
	return ids
}

// updateDocStorageKey sets the storage_key on a document after upload.
func (h *KnowledgeBaseHandler) updateDocStorageKey(c *gin.Context, docID, storageKey string) {
	h.stores.KnowledgeBases.UpdateDocumentStorageKey(c.Request.Context(), docID, storageKey)
}

// toKBResponse converts a model to the API response type.
func toKBResponse(kb *models.KnowledgeBase) kbResponse {
	return kbResponse{
		ID:              kb.ID,
		Name:            kb.Name,
		Description:     kb.Description,
		Scope:           kb.Scope,
		OwnerID:         kb.OwnerID,
		TeamID:          kb.TeamID,
		EmbeddingConfig: kb.EmbeddingConfig,
		DocumentCount:   kb.DocumentCount,
		ChunkCount:      kb.ChunkCount,
		TotalBytes:      kb.TotalBytes,
		Status:          kb.Status,
		CreatedAt:       kb.CreatedAt.Format("2006-01-02T15:04:05Z"),
		UpdatedAt:       kb.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

// toDocResponse converts a document model to the API response type.
func toDocResponse(doc *models.KBDocument) docResponse {
	return docResponse{
		ID:          doc.ID,
		KBID:        doc.KBID,
		Filename:    doc.Filename,
		ContentType: doc.ContentType,
		SizeBytes:   doc.SizeBytes,
		ChunkCount:  doc.ChunkCount,
		Status:      doc.Status,
		Error:       doc.Error,
		UploadedBy:  doc.UploadedBy,
		CreatedAt:   doc.CreatedAt.Format("2006-01-02T15:04:05Z"),
		UpdatedAt:   doc.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

// ── Channel KB Toggle ────────────────────────

// GetChannelKBs returns knowledge bases linked to a channel.
// GET /api/v1/channels/:id/knowledge-bases
func (h *KnowledgeBaseHandler) GetChannelKBs(c *gin.Context) {
	channelID := c.Param("id")
	userID := getUserID(c)

	if !userOwnsChannel(c, channelID, userID) {
		return
	}

	kbs, err := h.stores.KnowledgeBases.GetChannelKBs(c.Request.Context(), channelID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel knowledge bases"})
		return
	}

	if kbs == nil {
		kbs = []models.ChannelKB{}
	}

	c.JSON(http.StatusOK, gin.H{"data": kbs})
}

// SetChannelKBs links knowledge bases to a channel (replaces existing links).
// PUT /api/v1/channels/:id/knowledge-bases
// Body: { "kb_ids": ["uuid1", "uuid2"] }
func (h *KnowledgeBaseHandler) SetChannelKBs(c *gin.Context) {
	channelID := c.Param("id")
	userID := getUserID(c)

	if !userOwnsChannel(c, channelID, userID) {
		return
	}

	var req struct {
		KBIDs []string `json:"kb_ids" binding:"required"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// Validate that user has access to all requested KBs
	teamIDs := h.getUserTeamIDs(c, userID)
	for _, kbID := range req.KBIDs {
		kb, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kbID)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("knowledge base %s not found", kbID)})
			return
		}
		if !h.userCanAccess(kb, userID, teamIDs) {
			c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("no access to knowledge base %s", kbID)})
			return
		}
	}

	if err := h.stores.KnowledgeBases.SetChannelKBs(c.Request.Context(), channelID, req.KBIDs); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel knowledge bases"})
		return
	}

	// Return the updated list
	kbs, _ := h.stores.KnowledgeBases.GetChannelKBs(c.Request.Context(), channelID)
	if kbs == nil {
		kbs = []models.ChannelKB{}
	}

	h.auditLog(c, "kb.channel.link", channelID, map[string]interface{}{
		"channel_id": channelID, "kb_ids": req.KBIDs,
	})

	c.JSON(http.StatusOK, gin.H{"data": kbs})
}

// ── Discoverable Management (v0.17.0) ────────────

// SetDiscoverable toggles KB visibility to users.
// Only the KB owner or a system admin may change this flag.
func (h *KnowledgeBaseHandler) SetDiscoverable(c *gin.Context) {
	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	// Require ownership or admin role to toggle discoverability
	userID := getUserID(c)
	role, _ := c.Get("role")
	isAdmin := role == "admin"
	isOwner := kb.OwnerID != nil && *kb.OwnerID == userID

	if !isAdmin && !isOwner {
		c.JSON(http.StatusForbidden, gin.H{"error": "only the KB owner or a system admin can change discoverability"})
		return
	}

	var req struct {
		Discoverable bool `json:"discoverable"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if err := h.stores.KnowledgeBases.SetDiscoverable(c.Request.Context(), kb.ID, req.Discoverable); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update discoverability"})
		return
	}

	h.auditLog(c, "kb.discoverable", kb.ID, map[string]interface{}{
		"kb_name": kb.Name, "discoverable": req.Discoverable,
	})

	c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

// ListDiscoverableKBs returns KBs the user can see that are marked discoverable.
// Used by the channel KB toggle popup when kb_direct_access is enabled.
func (h *KnowledgeBaseHandler) ListDiscoverableKBs(c *gin.Context) {
	userID := getUserID(c)
	teamIDs := h.getUserTeamIDs(c, userID)

	kbs, err := h.stores.KnowledgeBases.ListDiscoverable(c.Request.Context(), userID, teamIDs)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"})
		return
	}
	if kbs == nil {
		kbs = []models.KnowledgeBase{}
	}

	items := make([]kbResponse, len(kbs))
	for i := range kbs {
		items[i] = toKBResponse(&kbs[i])
	}

	c.JSON(http.StatusOK, gin.H{"data": items})
}

// userCanAccess checks if a user can access a KB based on scope.
func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool {
	switch kb.Scope {
	case "global":
		return true
	case "personal":
		return kb.OwnerID != nil && *kb.OwnerID == userID
	case "team":
		if kb.TeamID == nil {
			return false
		}
		for _, tid := range teamIDs {
			if tid == *kb.TeamID {
				return true
			}
		}
		return false
	}
	return false
}

// ── KB Hint for System Prompt ────────────────

// BuildKBHint returns a system prompt fragment listing active KBs for a
// channel, or empty string if none are active. Called by the completion
// handler to nudge the LLM to use kb_search.
//
// Deprecated: v0.29.0 — replaced by filters.KBInjectFilter in the
// pre-completion filter chain. Kept for backward compatibility with
// any callers outside the main completion path. Will be removed in v0.30.0.
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, personaID string) string {
	teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID)

	// Collect KB details for the hint
	type kbInfo struct {
		Name     string
		DocCount int
	}
	var kbs []kbInfo
	seen := make(map[string]bool)

	// Persona-bound KBs (v0.17.0)
	if personaID != "" {
		personaKBs, err := stores.Personas.GetKBs(ctx, personaID)
		if err == nil {
			for _, pkb := range personaKBs {
				if pkb.ChunkCount > 0 {
					kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount})
					seen[pkb.KBID] = true
				}
			}
		}
	}

	// Project-bound KBs (v0.19.0)
	if stores.Projects != nil {
		projID, _ := stores.Projects.GetProjectIDForChannel(ctx, channelID)
		if projID != "" {
			projKBIDs, projErr := stores.Projects.GetKBIDs(ctx, projID)
			if projErr == nil {
				for _, kbID := range projKBIDs {
					if !seen[kbID] {
						kb, kbErr := stores.KnowledgeBases.GetByID(ctx, kbID)
						if kbErr == nil && kb.ChunkCount > 0 {
							kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
							seen[kbID] = true
						}
					}
				}
			}
		}
	}

	// Channel-linked KBs
	channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
	if err == nil {
		for _, ckb := range channelKBs {
			if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] {
				kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount})
				seen[ckb.KBID] = true
			}
		}
	}

	// Personal KBs (not already counted)
	personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
	if err == nil {
		for _, kb := range personalKBs {
			if !seen[kb.ID] && kb.ChunkCount > 0 {
				kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
				seen[kb.ID] = true
			}
		}
	}

	// Group-granted KBs are resolved via GetActiveKBIDs — only add names
	// for KBs found through channel link or persona. The kb_search tool
	// handles the full resolution at search time.
	_ = teamIDs // used by GetActiveKBIDsWithPersona at search time

	if len(kbs) == 0 {
		return ""
	}

	hint := "\nYou have access to the following knowledge bases:\n"
	for _, kb := range kbs {
		hint += fmt.Sprintf("- \"%s\" (%d documents)\n", kb.Name, kb.DocCount)
	}
	hint += "Use the kb_search tool to find relevant information from these sources when the user asks questions that might be answered by their documents."

	return hint
}

// ── Audit Logging ──────────────────────────────

// safeFilename safely extracts the filename from a deleted document.
func safeFilename(doc *models.KBDocument) string {
	if doc == nil {
		return ""
	}
	return doc.Filename
}

// auditLog records a KB operation in the audit log.
func (h *KnowledgeBaseHandler) auditLog(c *gin.Context, action, resourceID string, metadata map[string]interface{}) {
	if h.stores.Audit == nil {
		return
	}

	userID := getUserID(c)
	var meta models.JSONMap
	if metadata != nil {
		meta = models.JSONMap(metadata)
	}

	entry := &models.AuditEntry{
		ActorID:      &userID,
		Action:       action,
		ResourceType: "knowledge_base",
		ResourceID:   resourceID,
		Metadata:     meta,
		IPAddress:    c.ClientIP(),
		UserAgent:    c.GetHeader("User-Agent"),
	}

	if err := h.stores.Audit.Log(context.Background(), entry); err != nil {
		log.Printf("audit log error (KB): %v", err)
	}
}

// ── KB Rebuild ─────────────────────────────────

// RebuildKB re-chunks and re-embeds all documents in a knowledge base.
// POST /api/v1/knowledge-bases/:id/rebuild
func (h *KnowledgeBaseHandler) RebuildKB(c *gin.Context) {
	if h.ingester == nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingestion pipeline not configured"})
		return
	}

	kb, ok := h.loadAndAuthorize(c)
	if !ok {
		return
	}

	ctx := c.Request.Context()
	userID := getUserID(c)

	// List all documents in the KB
	docs, err := h.stores.KnowledgeBases.ListDocuments(ctx, kb.ID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list documents"})
		return
	}

	if len(docs) == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "knowledge base has no documents to rebuild"})
		return
	}

	// Resolve team for embedding role
	teamIDs := h.getUserTeamIDs(c, userID)
	var teamID *string
	if len(teamIDs) > 0 {
		teamID = &teamIDs[0]
	}

	// Delete all existing chunks (embeddings will be regenerated)
	rebuiltCount := 0
	for _, doc := range docs {
		if doc.ExtractedText == nil || *doc.ExtractedText == "" {
			// Skip docs without extracted text — they'd need re-extraction
			// which requires the original file in object store
			continue
		}

		// Clear existing chunks for this document
		if err := h.stores.KnowledgeBases.DeleteChunksForDocument(ctx, doc.ID); err != nil {
			log.Printf("⚠ rebuild: failed to delete chunks for doc %s: %v", doc.ID, err)
			continue
		}

		// Reset document status and re-ingest
		h.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "pending", nil)
		h.ingester.IngestDocument(kb, &doc, userID, teamID)
		rebuiltCount++
	}

	h.auditLog(c, "kb.rebuild", kb.ID, map[string]interface{}{
		"kb_name":        kb.Name,
		"total_docs":     len(docs),
		"rebuilt_docs":   rebuiltCount,
	})

	c.JSON(http.StatusAccepted, gin.H{
		"message":       "rebuild started",
		"total_docs":    len(docs),
		"rebuilding":    rebuiltCount,
		"skipped":       len(docs) - rebuiltCount,
	})
}
