package api

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base32"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/johanj/vault1984/lib"
	"github.com/pquerna/otp/totp"
)

// challenge holds an in-memory WebAuthn challenge.
type challenge struct {
	Data      []byte
	Type      string
	CreatedAt time.Time
}

// Handlers holds dependencies for HTTP handlers.
type Handlers struct {
	Cfg *lib.Config

	mu         sync.Mutex
	challenges map[string]challenge // in-memory challenge store (key = hex of challenge bytes)
}

// NewHandlers creates a new Handlers instance.
func NewHandlers(cfg *lib.Config) *Handlers {
	h := &Handlers{Cfg: cfg, challenges: make(map[string]challenge)}
	go func() {
		for {
			time.Sleep(60 * time.Second)
			h.cleanChallenges()
		}
	}()
	return h
}

func (h *Handlers) storeChallenge(data []byte, typ string) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.challenges[hex.EncodeToString(data)] = challenge{Data: data, Type: typ, CreatedAt: time.Now()}
}

func (h *Handlers) consumeChallenge(data []byte, typ string) error {
	h.mu.Lock()
	defer h.mu.Unlock()
	key := hex.EncodeToString(data)
	c, ok := h.challenges[key]
	if !ok || c.Type != typ || time.Since(c.CreatedAt) > 5*time.Minute {
		return fmt.Errorf("challenge not found or expired")
	}
	delete(h.challenges, key)
	return nil
}

func (h *Handlers) cleanChallenges() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for k, c := range h.challenges {
		if time.Since(c.CreatedAt) > 5*time.Minute {
			delete(h.challenges, k)
		}
	}
}

// db returns the vault DB for this request (from context, set by VaultMiddleware).
func (h *Handlers) db(r *http.Request) *lib.DB {
	return DBFromContext(r.Context())
}

// vk returns the vault key for this request (from context, set by VaultMiddleware).
func (h *Handlers) vk(r *http.Request) []byte {
	return VaultKeyFromContext(r.Context())
}

// ---------------------------------------------------------------------------
// Health & Auth
// ---------------------------------------------------------------------------

// VaultInfo returns the vault ID for the current request.
func (h *Handlers) VaultInfo(w http.ResponseWriter, r *http.Request) {
	db := h.db(r)
	if db == nil {
		ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault initialized")
		return
	}
	base := filepath.Base(db.DBPath)
	vaultID := strings.TrimSuffix(base, ".db")
	JSONResponse(w, http.StatusOK, map[string]string{"vault_id": vaultID})
}

// Version is derived from the binary's modification time.
var Version = func() string {
	exe, err := os.Executable()
	if err != nil {
		return "dev"
	}
	info, err := os.Stat(exe)
	if err != nil {
		return "dev"
	}
	return info.ModTime().UTC().Format("20060102-1504")
}()

// Health returns server status.
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
	var count int
	if db := h.db(r); db != nil {
		count, _ = lib.EntryCount(db)
	}
	JSONResponse(w, http.StatusOK, map[string]any{
		"status":  "ok",
		"version": Version,
		"entries": count,
		"time":    time.Now().UTC().Format(time.RFC3339),
	})
}

// Setup creates a session (test-only endpoint).
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
	session, err := lib.SessionCreate(h.db(r), h.Cfg.SessionTTL, lib.ActorWeb)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session")
		return
	}

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		Action: "setup",
		Actor:  lib.ActorWeb,
		IPAddr: realIP(r),
	})

	JSONResponse(w, http.StatusOK, map[string]string{
		"token": session.Token,
	})
}

// AuthStatus returns whether the vault is fresh (no credentials) or locked.
func (h *Handlers) AuthStatus(w http.ResponseWriter, r *http.Request) {
	db := h.db(r)
	if db == nil {
		JSONResponse(w, http.StatusOK, map[string]any{"state": "fresh", "credentials": 0})
		return
	}
	count, err := lib.WebAuthnCredentialCount(db)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "db_error", "Failed to check vault status")
		return
	}
	state := "fresh"
	if count > 0 {
		state = "locked"
	}
	JSONResponse(w, http.StatusOK, map[string]any{
		"state":       state,
		"credentials": count,
	})
}

// rpID extracts the RP ID from the Host header (strips port).
func rpID(r *http.Request) string {
	host := r.Host
	if idx := strings.Index(host, ":"); idx != -1 {
		host = host[:idx]
	}
	if host == "127.0.0.1" || host == "::1" {
		return "localhost"
	}
	return host
}

// hasValidSession checks if the request has a valid Bearer token.
func (h *Handlers) hasValidSession(r *http.Request) bool {
	auth := r.Header.Get("Authorization")
	if !strings.HasPrefix(auth, "Bearer ") {
		return false
	}
	db := h.db(r)
	if db == nil {
		return false
	}
	session, err := lib.SessionGet(db, strings.TrimPrefix(auth, "Bearer "))
	return err == nil && session != nil
}

// AuthRegisterBegin starts WebAuthn registration.
// First passkey: no auth needed (DB may not exist yet). Additional passkeys: valid session required.
func (h *Handlers) AuthRegisterBegin(w http.ResponseWriter, r *http.Request) {
	db := h.db(r)
	if db != nil {
		count, _ := lib.WebAuthnCredentialCount(db)
		if count > 0 && !h.hasValidSession(r) {
			ErrorResponse(w, http.StatusForbidden, "already_registered", "Vault already has credentials. Authenticate first to add more.")
			return
		}
	}

	challengeBytes := make([]byte, 32)
	rand.Read(challengeBytes)
	h.storeChallenge(challengeBytes, "register")

	JSONResponse(w, http.StatusOK, map[string]any{
		"publicKey": map[string]any{
			"challenge": challengeBytes,
			"rp":        map[string]string{"name": "Vault1984", "id": rpID(r)},
			"user": map[string]any{
				"id":          []byte("vault1984-owner"),
				"name":        "vault-owner",
				"displayName": "Vault Owner",
			},
			"pubKeyCredParams": []map[string]any{
				{"type": "public-key", "alg": -7},
				{"type": "public-key", "alg": -257},
			},
			"authenticatorSelection": map[string]any{
				"residentKey":      "preferred",
				"userVerification": "required",
			},
			"attestation": "none",
			"extensions": map[string]any{
				"prf": map[string]any{},
			},
		},
	})
}

// AuthRegisterComplete finishes WebAuthn registration and creates a session.
func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request) {
	db := h.db(r)
	if db != nil {
		count, _ := lib.WebAuthnCredentialCount(db)
		if count > 0 && !h.hasValidSession(r) {
			ErrorResponse(w, http.StatusForbidden, "already_registered", "Vault already has credentials. Authenticate first to add more.")
			return
		}
	}

	var req struct {
		Challenge    []byte `json:"challenge"`
		CredentialID []byte `json:"credential_id"`
		PublicKey    []byte `json:"public_key"`
		PRFSalt      []byte `json:"prf_salt"`
		Name         string `json:"name"`
		L1Key        []byte `json:"l1_key"` // first 8 bytes of master, for vault DB naming
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	if err := h.consumeChallenge(req.Challenge, "register"); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_challenge", "Challenge verification failed")
		return
	}

	// First passkey → create DB
	if db == nil && len(req.PublicKey) > 0 {
		// DB named from L1 key: vault1984-XXXXXX (base64url of first 4 bytes, no extension)
		var dbName string
		if len(req.L1Key) >= 4 {
			dbName = "vault1984-" + base64UrlEncode(req.L1Key[:4])
		} else {
			// Fallback: derive from public key hash (legacy compat)
			hash := sha256.Sum256(req.PublicKey)
			dbName = "vault1984-" + base64UrlEncode(hash[:4])
		}
		dbPath := filepath.Join(h.Cfg.DataDir, dbName)
		newDB, err := lib.OpenDB(dbPath)
		if err != nil {
			log.Printf("DB create failed: path=%s err=%v", dbPath, err)
			ErrorResponse(w, http.StatusInternalServerError, "db_create_failed", "Failed to create vault database")
			return
		}
		defer newDB.Close()
		if err := lib.MigrateDB(newDB); err != nil {
			ErrorResponse(w, http.StatusInternalServerError, "db_migrate_failed", "Failed to initialize vault database")
			return
		}
		db = newDB
		log.Printf("Vault created: %s", dbPath)
	}

	if req.Name == "" {
		req.Name = "Primary Passkey"
	}
	cred := &lib.WebAuthnCredential{
		CredID:       lib.HexID(lib.NewID()),
		Name:         req.Name,
		CredentialID: req.CredentialID,
		PublicKey:    req.PublicKey,
		PRFSalt:      req.PRFSalt,
	}
	if err := lib.StoreWebAuthnCredential(db, cred); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential")
		return
	}

	session, err := lib.SessionCreate(db, h.Cfg.SessionTTL, lib.ActorWeb)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session")
		return
	}

	lib.AuditLog(db, &lib.AuditEvent{
		Action: "register",
		Actor:  lib.ActorWeb,
		IPAddr: realIP(r),
	})

	JSONResponse(w, http.StatusCreated, map[string]any{
		"token":   session.Token,
		"cred_id": cred.CredID,
	})
}

// AuthLoginBegin starts WebAuthn authentication.
func (h *Handlers) AuthLoginBegin(w http.ResponseWriter, r *http.Request) {
	creds, err := lib.GetWebAuthnCredentials(h.db(r))
	if err != nil || len(creds) == 0 {
		ErrorResponse(w, http.StatusNotFound, "no_credentials", "No credentials registered")
		return
	}

	challenge := make([]byte, 32)
	rand.Read(challenge)
	h.storeChallenge(challenge, "login")

	var allowCreds []map[string]any
	var prfSalt []byte
	for _, c := range creds {
		allowCreds = append(allowCreds, map[string]any{
			"type": "public-key",
			"id":   c.CredentialID,
		})
		if len(c.PRFSalt) > 0 {
			prfSalt = c.PRFSalt
		}
	}

	prfExt := map[string]any{}
	if len(prfSalt) > 0 {
		prfExt["eval"] = map[string]any{"first": prfSalt}
	}

	JSONResponse(w, http.StatusOK, map[string]any{
		"publicKey": map[string]any{
			"challenge":        challenge,
			"rpId":             rpID(r),
			"allowCredentials": allowCreds,
			"userVerification": "required",
			"extensions":       map[string]any{"prf": prfExt},
		},
	})
}

// AuthLoginComplete finishes WebAuthn authentication and creates a session.
func (h *Handlers) AuthLoginComplete(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Challenge    []byte `json:"challenge"`
		CredentialID []byte `json:"credential_id"`
		SignCount    int    `json:"sign_count"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	if err := h.consumeChallenge(req.Challenge, "login"); err != nil {
		ErrorResponse(w, http.StatusUnauthorized, "invalid_challenge", "Challenge verification failed")
		return
	}

	cred, err := lib.GetWebAuthnCredentialByRawID(h.db(r), req.CredentialID)
	if err != nil {
		ErrorResponse(w, http.StatusUnauthorized, "unknown_credential", "Credential not recognized")
		return
	}

	// Cloned authenticator detection
	if req.SignCount > 0 && req.SignCount <= cred.SignCount {
		lib.AuditLog(h.db(r), &lib.AuditEvent{
			Action: "cloned_authenticator_warning",
			Actor:  lib.ActorWeb,
			IPAddr: realIP(r),
		})
	}
	lib.UpdateWebAuthnSignCount(h.db(r), int64(cred.CredID), req.SignCount)

	session, err := lib.SessionCreate(h.db(r), h.Cfg.SessionTTL, lib.ActorWeb)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session")
		return
	}

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		Action: "login",
		Actor:  lib.ActorWeb,
		IPAddr: realIP(r),
	})

	JSONResponse(w, http.StatusOK, map[string]any{
		"token": session.Token,
	})
}

// ---------------------------------------------------------------------------
// Entry CRUD
// ---------------------------------------------------------------------------

// ListEntries returns all entries (tree structure).
func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())
	var parent *int64
	if pidStr := r.URL.Query().Get("parent_id"); pidStr != "" {
		pid, err := lib.HexToID(pidStr)
		if err != nil {
			ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid parent_id")
			return
		}
		parent = &pid
	}

	entries, err := lib.EntryList(h.db(r), h.vk(r), parent)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
		return
	}
	if entries == nil {
		entries = []lib.Entry{}
	}

	// Strip L2 field values for MCP/agent actors; web/extension decrypt client-side
	if actor == lib.ActorMCP || actor == lib.ActorAgent {
		for i := range entries {
			if entries[i].VaultData != nil {
				stripL2Fields(entries[i].VaultData)
			}
		}
	}

	JSONResponse(w, http.StatusOK, entries)
}

// GetEntry returns a single entry.
func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())
	entryID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
		return
	}

	entry, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
	if err == lib.ErrNotFound {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
		return
	}
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
		return
	}

	// Check if soft-deleted
	if entry.DeletedAt != nil {
		ErrorResponse(w, http.StatusNotFound, "deleted", "Entry has been deleted")
		return
	}

	// Strip L2 field values for MCP actors only; web/extension decrypt client-side
	if actor == lib.ActorMCP && entry.VaultData != nil {
		stripL2Fields(entry.VaultData)
	}

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		EntryID: entry.EntryID,
		Title:   entry.Title,
		Action:  lib.ActionRead,
		Actor:   actor,
		IPAddr:  realIP(r),
	})

	JSONResponse(w, http.StatusOK, entry)
}

// CreateEntry creates a new entry.
func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())

	var req struct {
		Type     string         `json:"type"`
		Title    string         `json:"title"`
		ParentID lib.HexID      `json:"parent_id"`
		Data     *lib.VaultData `json:"data"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	if req.Title == "" {
		ErrorResponse(w, http.StatusBadRequest, "missing_title", "Title is required")
		return
	}
	if req.Type == "" {
		req.Type = lib.TypeCredential
	}

	entry := &lib.Entry{
		Type:      req.Type,
		Title:     req.Title,
		ParentID:  req.ParentID,
		DataLevel: lib.DataLevelL1,
		VaultData: req.Data,
	}

	if err := lib.EntryCreate(h.db(r), h.vk(r), entry); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create entry")
		return
	}

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		EntryID: entry.EntryID,
		Title:   entry.Title,
		Action:  lib.ActionCreate,
		Actor:   actor,
		IPAddr:  realIP(r),
	})

	JSONResponse(w, http.StatusCreated, entry)
}

// UpdateEntry updates an existing entry.
func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())
	entryID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
		return
	}

	var req struct {
		Type     string         `json:"type"`
		Title    string         `json:"title"`
		ParentID lib.HexID      `json:"parent_id"`
		Version  int            `json:"version"`
		Data     *lib.VaultData `json:"data"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	// Get existing entry
	existing, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
	if err == lib.ErrNotFound {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
		return
	}
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
		return
	}

	// Update fields
	if req.Title != "" {
		existing.Title = req.Title
	}
	if req.Type != "" {
		existing.Type = req.Type
	}
	existing.ParentID = req.ParentID
	existing.Version = req.Version
	if req.Data != nil {
		existing.VaultData = req.Data
	}

	if err := lib.EntryUpdate(h.db(r), h.vk(r), existing); err != nil {
		if err == lib.ErrVersionConflict {
			ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error())
			return
		}
		ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update entry")
		return
	}

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		EntryID: existing.EntryID,
		Title:   existing.Title,
		Action:  lib.ActionUpdate,
		Actor:   actor,
		IPAddr:  realIP(r),
	})

	JSONResponse(w, http.StatusOK, existing)
}

// DeleteEntry soft-deletes an entry.
func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())
	entryID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
		return
	}

	// Get entry for audit log
	entry, _ := lib.EntryGet(h.db(r), h.vk(r), entryID)

	if err := lib.EntryDelete(h.db(r), entryID); err != nil {
		if err == lib.ErrNotFound {
			ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
			return
		}
		ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete entry")
		return
	}

	title := ""
	if entry != nil {
		title = entry.Title
	}

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		EntryID: lib.HexID(entryID),
		Title:   title,
		Action:  lib.ActionDelete,
		Actor:   actor,
		IPAddr:  realIP(r),
	})

	JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}

// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------

// SearchEntries searches entries by title.
func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())
	query := r.URL.Query().Get("q")
	if query == "" {
		ErrorResponse(w, http.StatusBadRequest, "missing_query", "Query parameter 'q' is required")
		return
	}

	// Use fuzzy search for practicality
	entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Search failed")
		return
	}
	if entries == nil {
		entries = []lib.Entry{}
	}

	// Strip L2 field values for MCP/agent actors; web/extension decrypt client-side
	if actor == lib.ActorMCP || actor == lib.ActorAgent {
		for i := range entries {
			if entries[i].VaultData != nil {
				stripL2Fields(entries[i].VaultData)
			}
		}
	}

	JSONResponse(w, http.StatusOK, entries)
}

// ---------------------------------------------------------------------------
// Password Generator
// ---------------------------------------------------------------------------

// GeneratePassword generates a random password.
func (h *Handlers) GeneratePassword(w http.ResponseWriter, r *http.Request) {
	lengthStr := r.URL.Query().Get("length")
	length := 20
	if lengthStr != "" {
		if l, err := strconv.Atoi(lengthStr); err == nil && l > 0 && l <= 128 {
			length = l
		}
	}

	symbols := r.URL.Query().Get("symbols") != "false"
	wordsParam := r.URL.Query().Get("words")
	wordsN := 0
	if wordsParam == "true" {
		wordsN = 4
	} else if n, err := strconv.Atoi(wordsParam); err == nil && n > 0 {
		wordsN = n
	}

	var password string
	if wordsN > 0 {
		password = generatePassphrase(wordsN)
	} else {
		password = generatePassword(length, symbols)
	}

	JSONResponse(w, http.StatusOK, map[string]string{
		"password": password,
	})
}

func generatePassword(length int, symbols bool) string {
	const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
	const digits = "0123456789"
	const syms = "!@#$%^&*()_+-=[]{}|;:,.<>?"
	
	charset := letters + digits
	if symbols {
		charset += syms
	}

	b := make([]byte, length)
	rand.Read(b)
	for i := range b {
		b[i] = charset[int(b[i])%len(charset)]
	}
	return string(b)
}

func generatePassphrase(words int) string {
	wordList := []string{
		"correct", "horse", "battery", "staple", "cloud", "mountain",
		"river", "forest", "castle", "dragon", "phoenix", "crystal",
		"shadow", "thunder", "whisper", "harvest", "journey", "compass",
		"anchor", "beacon", "bridge", "canyon", "desert", "empire",
	}
	b := make([]byte, words)
	rand.Read(b)
	parts := make([]string, words)
	for i := range parts {
		parts[i] = wordList[int(b[i])%len(wordList)]
	}
	return strings.Join(parts, "-")
}

// ---------------------------------------------------------------------------
// Extension API
// ---------------------------------------------------------------------------

// GetTOTP generates a live TOTP code for an entry.
func (h *Handlers) GetTOTP(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())
	entryID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
		return
	}

	entry, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
	if err == lib.ErrNotFound {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
		return
	}
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
		return
	}

	if entry.VaultData == nil {
		ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field")
		return
	}

	// Find TOTP field
	var totpSeed string
	var isL2 bool
	for _, field := range entry.VaultData.Fields {
		if field.Kind == "totp" {
			if field.L2 {
				isL2 = true
			} else {
				totpSeed = field.Value
			}
			break
		}
	}

	if isL2 {
		JSONResponse(w, http.StatusOK, map[string]any{
			"l2": true,
		})
		return
	}

	if totpSeed == "" {
		ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field")
		return
	}

	// Normalize seed (remove spaces, uppercase)
	totpSeed = strings.ToUpper(strings.ReplaceAll(totpSeed, " ", ""))

	// Generate TOTP code
	code, err := totp.GenerateCode(totpSeed, time.Now())
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_totp", "Invalid TOTP seed")
		return
	}

	// Calculate time until expiry (30 second window)
	now := time.Now().Unix()
	expiresIn := 30 - (now % 30)

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		EntryID: entry.EntryID,
		Title:   entry.Title,
		Action:  "totp",
		Actor:   actor,
		IPAddr:  realIP(r),
	})

	JSONResponse(w, http.StatusOK, map[string]any{
		"code":       code,
		"expires_in": expiresIn,
		"l2":         false,
	})
}

// MatchURL finds credentials matching a URL (for extension popup).
func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) {
	urlStr := r.URL.Query().Get("url")
	if urlStr == "" {
		ErrorResponse(w, http.StatusBadRequest, "missing_url", "URL parameter required")
		return
	}

	// Extract domain from URL
	domain := extractDomain(urlStr)

	// Get all entries and filter by URL
	entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
		return
	}

	var matches []lib.Entry
	for _, entry := range entries {
		if entry.VaultData == nil {
			continue
		}
		for _, u := range entry.VaultData.URLs {
			if strings.Contains(u, domain) || strings.Contains(domain, extractDomain(u)) {
				matches = append(matches, entry)
				break
			}
		}
	}

	if matches == nil {
		matches = []lib.Entry{}
	}

	JSONResponse(w, http.StatusOK, matches)
}

// MapFields uses LLM to map vault fields to form fields.
func (h *Handlers) MapFields(w http.ResponseWriter, r *http.Request) {
	if h.Cfg.FireworksAPIKey == "" {
		ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "LLM not configured — set LLM_API_KEY in your environment")
		return
	}

	var req struct {
		EntryID    lib.HexID `json:"entry_id"`
		PageFields []struct {
			Selector    string `json:"selector"`
			Label       string `json:"label"`
			Type        string `json:"type"`
			Placeholder string `json:"placeholder"`
		} `json:"page_fields"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	entry, err := lib.EntryGet(h.db(r), h.vk(r), int64(req.EntryID))
	if err != nil {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
		return
	}

	if entry.VaultData == nil {
		ErrorResponse(w, http.StatusBadRequest, "no_data", "Entry has no data")
		return
	}

	// Build field lists for LLM
	var vaultFields []string
	for _, f := range entry.VaultData.Fields {
		if !f.L2 { // Only include L1 fields
			vaultFields = append(vaultFields, f.Label)
		}
	}

	var formFields []string
	for _, f := range req.PageFields {
		desc := f.Selector
		if f.Label != "" {
			desc = f.Label + " (" + f.Selector + ")"
		}
		formFields = append(formFields, desc)
	}

	// Call LLM
	prompt := fmt.Sprintf(`Map these vault fields to these HTML form fields. Return JSON object mapping vault_field_label to css_selector.

Vault fields: %s
Form fields: %s

Return ONLY valid JSON, no explanation. Example: {"Username":"#email","Password":"#password"}`,
		strings.Join(vaultFields, ", "),
		strings.Join(formFields, ", "))

	llmResp, err := callLLM(h.Cfg, "You are a field mapping assistant. Map credential fields to form fields.", prompt)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "llm_failed", "LLM request failed")
		return
	}

	// Parse LLM response
	var mapping map[string]string
	if err := json.Unmarshal([]byte(llmResp), &mapping); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response")
		return
	}

	JSONResponse(w, http.StatusOK, mapping)
}

// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------

// ImportEntries parses known formats directly; falls back to chunked LLM for unknown formats.
func (h *Handlers) ImportEntries(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())

	if err := r.ParseMultipartForm(32 << 20); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_form", "Failed to parse form")
		return
	}
	file, _, err := r.FormFile("file")
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "missing_file", "File is required")
		return
	}
	defer file.Close()

	content, err := io.ReadAll(file)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "read_failed", "Failed to read file")
		return
	}

	// Try direct parsers first (fast, free, handles 12k+ entries)
	var entries []lib.VaultData
	if parsed, ok := lib.DetectAndParse(content); ok {
		entries = parsed
	} else {
		// Unknown format — LLM in chunks of 100 rows
		if h.Cfg.FireworksAPIKey == "" {
			ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "Unknown import format and LLM not configured — set LLM_API_KEY to enable AI-assisted import")
			return
		}
		entries, err = parseLLMFormat(h.Cfg, content)
		if err != nil {
			ErrorResponse(w, http.StatusInternalServerError, "llm_failed", err.Error())
			return
		}
		lib.AutoL2Fields(entries)
	}

	// Classify entries against existing vault
	existingAll, _ := lib.EntryList(h.db(r), h.vk(r), nil)
	existingIndex := map[string]lib.HexID{}
	for _, e := range existingAll {
		if e.VaultData == nil {
			continue
		}
		existingIndex[importDedupKey(e.VaultData)] = e.EntryID
	}

	type PreviewEntry struct {
		lib.VaultData
		Status     string    `json:"status"`
		ExistingID lib.HexID `json:"existing_id,omitempty"`
	}

	batchSeen := map[string]bool{}
	preview := make([]PreviewEntry, 0, len(entries))
	for _, vd := range entries {
		key := importDedupKey(&vd)
		pe := PreviewEntry{VaultData: vd}
		if batchSeen[key] {
			pe.Status = "duplicate"
		} else if existingID, found := existingIndex[key]; found {
			pe.Status = "update"
			pe.ExistingID = existingID
		} else {
			pe.Status = "new"
		}
		batchSeen[key] = true
		preview = append(preview, pe)
	}

	newCount, updateCount, dupCount := 0, 0, 0
	for _, pe := range preview {
		switch pe.Status {
		case "new":
			newCount++
		case "update":
			updateCount++
		case "duplicate":
			dupCount++
		}
	}

	// Return first 100 for preview UI; client confirms all
	previewSlice := preview
	if len(previewSlice) > 100 {
		previewSlice = previewSlice[:100]
	}

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		Action: lib.ActionImport,
		Actor:  actor,
		IPAddr: realIP(r),
		Title:  fmt.Sprintf("%d parsed: %d new, %d update, %d duplicate", len(entries), newCount, updateCount, dupCount),
	})

	JSONResponse(w, http.StatusOK, map[string]any{
		"entries":     previewSlice,
		"all_entries": preview, // full list for confirm
		"total":       len(preview),
		"new":         newCount,
		"update":      updateCount,
		"duplicates":  dupCount,
	})
}

// parseLLMFormat detects the column mapping of an unknown format using the LLM,
// then maps all rows client-side. Only sends headers + 1 masked sample row to the LLM —
// never actual credential values.
func parseLLMFormat(cfg *lib.Config, content []byte) ([]lib.VaultData, error) {
	lines := strings.Split(strings.TrimSpace(string(content)), "\n")
	if len(lines) < 2 {
		return nil, fmt.Errorf("file too short to detect format")
	}

	header := lines[0]
	// Build a masked sample row — replace all values with their column name
	// so the LLM sees structure, never real data.
	sampleRow := maskSampleRow(header, lines[1])

	prompt := fmt.Sprintf(`You are analyzing a password manager export format.
Here is the header row and ONE masked sample row (values replaced with column names — no real data):

Header: %s
Sample: %s

Map each column to our vault schema. Return ONLY this JSON, no explanation:
{
  "title": "<column name for entry title>",
  "url": "<column name for URL, or null>",
  "username": "<column name for username/email, or null>",
  "password": "<column name for password, or null>",
  "totp": "<column name for TOTP/2FA secret, or null>",
  "notes": "<column name for notes, or null>",
  "extra_fields": ["<any other column names worth keeping>"]
}`, header, sampleRow)

	resp, err := callLLM(cfg, "You are a data format analyzer. Return only JSON.", prompt)
	if err != nil {
		return nil, fmt.Errorf("LLM format detection failed: %w", err)
	}

	// Parse the column mapping
	start := strings.Index(resp, "{")
	end := strings.LastIndex(resp, "}")
	if start < 0 || end <= start {
		return nil, fmt.Errorf("LLM returned invalid JSON mapping")
	}

	var mapping struct {
		Title       string   `json:"title"`
		URL         string   `json:"url"`
		Username    string   `json:"username"`
		Password    string   `json:"password"`
		TOTP        string   `json:"totp"`
		Notes       string   `json:"notes"`
		ExtraFields []string `json:"extra_fields"`
	}
	if err := json.Unmarshal([]byte(resp[start:end+1]), &mapping); err != nil {
		return nil, fmt.Errorf("failed to parse LLM mapping: %w", err)
	}

	// Parse header into column index map
	cols := parseCSVLine(header)
	idx := map[string]int{}
	for i, col := range cols {
		idx[strings.TrimSpace(col)] = i
	}

	col := func(name string, row []string) string {
		if name == "" {
			return ""
		}
		if i, ok := idx[name]; ok && i < len(row) {
			return strings.TrimSpace(row[i])
		}
		return ""
	}

	// Map all data rows using the detected column mapping
	var entries []lib.VaultData
	for _, line := range lines[1:] {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		row := parseCSVLine(line)

		title := col(mapping.Title, row)
		if title == "" {
			title = col(mapping.URL, row)
		}
		if title == "" {
			title = "Imported entry"
		}

		vd := lib.VaultData{
			Title: title,
			Type:  lib.TypeCredential,
		}

		if u := col(mapping.URL, row); u != "" {
			vd.URLs = []string{u}
		}
		if v := col(mapping.Username, row); v != "" {
			vd.Fields = append(vd.Fields, lib.VaultField{Label: "username", Value: v, Kind: "text"})
		}
		if v := col(mapping.Password, row); v != "" {
			vd.Fields = append(vd.Fields, lib.VaultField{Label: "password", Value: v, Kind: "password"})
		}
		if v := col(mapping.TOTP, row); v != "" {
			vd.Fields = append(vd.Fields, lib.VaultField{Label: "totp", Value: v, Kind: "totp"})
		}
		for _, extra := range mapping.ExtraFields {
			if v := col(extra, row); v != "" {
				vd.Fields = append(vd.Fields, lib.VaultField{Label: extra, Value: v, Kind: "text"})
			}
		}
		if v := col(mapping.Notes, row); v != "" {
			vd.Notes = v
		}

		entries = append(entries, vd)
	}

	return entries, nil
}

// maskSampleRow replaces each CSV value in a data row with its corresponding header name.
// Result is safe to send to an LLM — no actual credential data.
func maskSampleRow(header, dataRow string) string {
	headers := parseCSVLine(header)
	values := parseCSVLine(dataRow)
	masked := make([]string, len(headers))
	for i, h := range headers {
		if i < len(values) && values[i] != "" {
			masked[i] = "<" + strings.TrimSpace(h) + ">"
		} else {
			masked[i] = ""
		}
	}
	return strings.Join(masked, ",")
}

// parseCSVLine parses a single CSV line respecting quoted fields.
func parseCSVLine(line string) []string {
	var fields []string
	var cur strings.Builder
	inQuote := false
	for i := 0; i < len(line); i++ {
		c := line[i]
		if c == '"' {
			inQuote = !inQuote
		} else if c == ',' && !inQuote {
			fields = append(fields, cur.String())
			cur.Reset()
		} else {
			cur.WriteByte(c)
		}
	}
	fields = append(fields, cur.String())
	return fields
}

// ImportConfirm confirms and saves imported entries.
func (h *Handlers) ImportConfirm(w http.ResponseWriter, r *http.Request) {
	actor := ActorFromContext(r.Context())

	var req struct {
		Entries []lib.VaultData `json:"entries"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	// Build dedup index: normalized "url|username" → existing Entry (for upsert)
	existing, _ := lib.EntryList(h.db(r), h.vk(r), nil)
	dedupIndex := map[string]*lib.Entry{}
	for i, e := range existing {
		if e.VaultData == nil {
			continue
		}
		dedupIndex[importDedupKey(e.VaultData)] = &existing[i]
	}

	var created, updated, skipped int
	// Track intra-batch keys to avoid importing same entry twice
	batchSeen := map[string]bool{}
	for _, vd := range req.Entries {
		key := importDedupKey(&vd)
		if batchSeen[key] {
			continue // intra-batch duplicate — first occurrence wins within same batch
		}
		batchSeen[key] = true

		vdCopy := vd
		if existingEntry, found := dedupIndex[key]; found {
			// Collision: keep newest by source modification date.
			// If incoming has no date (e.g. Chrome CSV) → don't overwrite.
			// If incoming date > existing → overwrite.
			incomingMod := vdCopy.SourceModified
			existingMod := existingEntry.UpdatedAt
			shouldUpdate := incomingMod > 0 && incomingMod > existingMod
			if shouldUpdate {
				existingEntry.Title = vdCopy.Title
				existingEntry.Type = vdCopy.Type
				existingEntry.VaultData = &vdCopy
				if err := lib.EntryUpdate(h.db(r), h.vk(r), existingEntry); err == nil {
					updated++
				}
			} else if incomingMod == 0 {
				// No date in source — skip, existing wins
				skipped++
			} else {
				// Existing is newer — skip
				skipped++
			}
		} else {
			entry := &lib.Entry{
				Type:      vdCopy.Type,
				Title:     vdCopy.Title,
				DataLevel: lib.DataLevelL1,
				VaultData: &vdCopy,
			}
			if err := lib.EntryCreate(h.db(r), h.vk(r), entry); err == nil {
				created++
				dedupIndex[key] = entry
			}
		}
	}

	lib.AuditLog(h.db(r), &lib.AuditEvent{
		Action: lib.ActionImport,
		Actor:  actor,
		IPAddr: realIP(r),
		Title:  fmt.Sprintf("%d created, %d updated, %d skipped (date-based)", created, updated, skipped),
	})

	JSONResponse(w, http.StatusOK, map[string]any{"imported": created, "updated": updated, "skipped": skipped})
}

// ---------------------------------------------------------------------------
// Audit Log
// ---------------------------------------------------------------------------

// GetAuditLog returns recent audit events.
func (h *Handlers) GetAuditLog(w http.ResponseWriter, r *http.Request) {
	limitStr := r.URL.Query().Get("limit")
	limit := 100
	if limitStr != "" {
		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
			limit = l
		}
	}

	events, err := lib.AuditList(h.db(r), limit)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list audit events")
		return
	}
	if events == nil {
		events = []lib.AuditEvent{}
	}

	JSONResponse(w, http.StatusOK, events)
}

// ---------------------------------------------------------------------------
// MCP Endpoint
// ---------------------------------------------------------------------------

// MCPHandler handles JSON-RPC 2.0 MCP protocol requests.
func (h *Handlers) MCPHandler(w http.ResponseWriter, r *http.Request) {
	var req struct {
		JSONRPC string          `json:"jsonrpc"`
		ID      any             `json:"id"`
		Method  string          `json:"method"`
		Params  json.RawMessage `json:"params"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		mcpError(w, nil, -32700, "Parse error")
		return
	}

	if req.JSONRPC != "2.0" {
		mcpError(w, req.ID, -32600, "Invalid Request")
		return
	}

	// Check read-only enforcement for scoped MCP tokens
	mcpToken := MCPTokenFromContext(r.Context())
	if mcpToken != nil && mcpToken.ReadOnly {
		// Parse the call to check for write methods
		var call struct {
			Name string `json:"name"`
		}
		if req.Method == "tools/call" {
			json.Unmarshal(req.Params, &call)
			// Block write operations on read-only tokens
			if call.Name == "create_credential" || call.Name == "update_credential" || call.Name == "delete_credential" {
				mcpError(w, req.ID, -32000, "token is read-only")
				return
			}
		}
	}

	var result any
	var err error

	switch req.Method {
	case "tools/list":
		result = h.mcpToolsList()
	case "tools/call":
		result, err = h.mcpToolsCall(r, req.Params)
	default:
		mcpError(w, req.ID, -32601, "Method not found")
		return
	}

	if err != nil {
		mcpError(w, req.ID, -32000, err.Error())
		return
	}

	mcpSuccess(w, req.ID, result)
}

func (h *Handlers) mcpToolsList() map[string]any {
	return map[string]any{
		"tools": []map[string]any{
			{
				"name":        "get_credential",
				"description": "Search and return a credential from the vault. L2 fields are omitted.",
				"inputSchema": map[string]any{
					"type": "object",
					"properties": map[string]any{
						"query": map[string]string{"type": "string", "description": "Search query (title or URL)"},
					},
					"required": []string{"query"},
				},
			},
			{
				"name":        "list_credentials",
				"description": "List all credentials in the vault (titles, types, URLs only).",
				"inputSchema": map[string]any{
					"type": "object",
					"properties": map[string]any{
						"filter": map[string]string{"type": "string", "description": "Optional type filter"},
					},
				},
			},
			{
				"name":        "get_totp",
				"description": "Get a live TOTP code for an entry. Only works for L1 TOTP fields.",
				"inputSchema": map[string]any{
					"type": "object",
					"properties": map[string]any{
						"query": map[string]string{"type": "string", "description": "Entry title or ID"},
					},
					"required": []string{"query"},
				},
			},
			{
				"name":        "search_vault",
				"description": "Search the vault by title.",
				"inputSchema": map[string]any{
					"type": "object",
					"properties": map[string]any{
						"query": map[string]string{"type": "string", "description": "Search query"},
					},
					"required": []string{"query"},
				},
			},
			{
				"name":        "check_expiring",
				"description": "Check for entries with expiring credentials.",
				"inputSchema": map[string]any{
					"type": "object",
					"properties": map[string]any{
						"days": map[string]any{"type": "number", "description": "Days to check (default 30)"},
					},
				},
			},
		},
	}
}

func (h *Handlers) mcpToolsCall(r *http.Request, params json.RawMessage) (any, error) {
	var call struct {
		Name      string         `json:"name"`
		Arguments map[string]any `json:"arguments"`
	}
	if err := json.Unmarshal(params, &call); err != nil {
		return nil, fmt.Errorf("invalid params")
	}

	mcpToken := MCPTokenFromContext(r.Context())

	switch call.Name {
	case "get_credential", "search_vault":
		query, _ := call.Arguments["query"].(string)
		if query == "" {
			return nil, fmt.Errorf("query is required")
		}
		entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query)
		if err != nil {
			return nil, err
		}
		entries = mcpFilterEntries(entries, mcpToken)
		// Strip L2 fields
		for i := range entries {
			if entries[i].VaultData != nil {
				stripL2Fields(entries[i].VaultData)
			}
		}
		if len(entries) == 0 {
			return map[string]any{"content": []map[string]string{{"type": "text", "text": "No credentials found"}}}, nil
		}
		// For get_credential, return best match
		if call.Name == "get_credential" {
			result, _ := json.Marshal(entries[0])
			return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
		}
		result, _ := json.Marshal(entries)
		return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil

	case "list_credentials":
		filter, _ := call.Arguments["filter"].(string)
		entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
		if err != nil {
			return nil, err
		}
		entries = mcpFilterEntries(entries, mcpToken)
		var list []map[string]any
		for _, e := range entries {
			if filter != "" && e.Type != filter {
				continue
			}
			item := map[string]any{
				"entry_id": e.EntryID,
				"title":    e.Title,
				"type":     e.Type,
			}
			if e.VaultData != nil && len(e.VaultData.URLs) > 0 {
				item["urls"] = e.VaultData.URLs
			}
			list = append(list, item)
		}
		result, _ := json.Marshal(list)
		return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil

	case "get_totp":
		query, _ := call.Arguments["query"].(string)
		if query == "" {
			return nil, fmt.Errorf("query is required")
		}
		entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query)
		if err != nil || len(entries) == 0 {
			return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil
		}
		entries = mcpFilterEntries(entries, mcpToken)
		if len(entries) == 0 {
			return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil
		}
		entry := entries[0]
		if entry.VaultData == nil {
			return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil
		}
		for _, field := range entry.VaultData.Fields {
			if field.Kind == "totp" {
				if field.L2 {
					return map[string]any{"content": []map[string]string{{"type": "text", "text": "TOTP is L2 protected"}}}, nil
				}
				seed := strings.ToUpper(strings.ReplaceAll(field.Value, " ", ""))
				code, err := totp.GenerateCode(seed, time.Now())
				if err != nil {
					return map[string]any{"content": []map[string]string{{"type": "text", "text": "Invalid TOTP seed"}}}, nil
				}
				now := time.Now().Unix()
				expiresIn := 30 - (now % 30)
				result := fmt.Sprintf(`{"code":"%s","expires_in":%d}`, code, expiresIn)
				return map[string]any{"content": []map[string]string{{"type": "text", "text": result}}}, nil
			}
		}
		return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil

	case "check_expiring":
		daysF, _ := call.Arguments["days"].(float64)
		days := int(daysF)
		if days <= 0 {
			days = 30
		}
		entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
		if err != nil {
			return nil, err
		}
		entries = mcpFilterEntries(entries, mcpToken)
		cutoff := time.Now().AddDate(0, 0, days)
		var expiring []map[string]any
		for _, e := range entries {
			if e.VaultData == nil || e.VaultData.Expires == "" {
				continue
			}
			exp, err := time.Parse("2006-01-02", e.VaultData.Expires)
			if err != nil {
				continue
			}
			if exp.Before(cutoff) {
				daysRemaining := int(exp.Sub(time.Now()).Hours() / 24)
				expiring = append(expiring, map[string]any{
					"title":          e.Title,
					"type":           e.Type,
					"expires":        e.VaultData.Expires,
					"days_remaining": daysRemaining,
				})
			}
		}
		result, _ := json.Marshal(expiring)
		return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil

	default:
		return nil, fmt.Errorf("unknown tool: %s", call.Name)
	}
}

func mcpSuccess(w http.ResponseWriter, id any, result any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"jsonrpc": "2.0",
		"id":      id,
		"result":  result,
	})
}

func mcpError(w http.ResponseWriter, id any, code int, message string) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"jsonrpc": "2.0",
		"id":      id,
		"error": map[string]any{
			"code":    code,
			"message": message,
		},
	})
}

// ---------------------------------------------------------------------------
// MCP Token Management
// ---------------------------------------------------------------------------

// HandleCreateMCPToken creates a scoped MCP token.
func (h *Handlers) HandleCreateMCPToken(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Label     string      `json:"label"`
		EntryIDs  []lib.HexID `json:"entry_ids"`
		ReadOnly  bool        `json:"read_only"`
		ExpiresIn int64       `json:"expires_in_days"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}
	if req.Label == "" {
		ErrorResponse(w, http.StatusBadRequest, "missing_label", "Label is required")
		return
	}

	t := &lib.MCPToken{
		Label:    req.Label,
		EntryIDs: req.EntryIDs,
		ReadOnly: req.ReadOnly,
	}
	if req.ExpiresIn > 0 {
		t.ExpiresAt = time.Now().Unix() + req.ExpiresIn*86400
	}

	if err := lib.CreateMCPToken(h.db(r), t); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create MCP token")
		return
	}

	JSONResponse(w, http.StatusCreated, t)
}

// HandleListMCPTokens returns all MCP tokens.
func (h *Handlers) HandleListMCPTokens(w http.ResponseWriter, r *http.Request) {
	tokens, err := lib.ListMCPTokens(h.db(r))
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list MCP tokens")
		return
	}
	if tokens == nil {
		tokens = []lib.MCPToken{}
	}
	JSONResponse(w, http.StatusOK, tokens)
}

// HandleDeleteMCPToken revokes an MCP token.
func (h *Handlers) HandleDeleteMCPToken(w http.ResponseWriter, r *http.Request) {
	id, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid token ID")
		return
	}
	if err := lib.DeleteMCPToken(h.db(r), id); err != nil {
		if err == lib.ErrNotFound {
			ErrorResponse(w, http.StatusNotFound, "not_found", "Token not found")
			return
		}
		ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete MCP token")
		return
	}
	JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}

// ---------------------------------------------------------------------------
// WebAuthn PRF
// ---------------------------------------------------------------------------

// HandleWebAuthnRegisterBegin starts WebAuthn registration.
func (h *Handlers) HandleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
	// Generate a challenge
	challenge := make([]byte, 32)
	rand.Read(challenge)

	options := map[string]any{
		"publicKey": map[string]any{
			"challenge": challenge,
			"rp":        map[string]string{"name": "Vault1984", "id": rpID(r)},
			"user": map[string]any{
				"id":          []byte("vault1984-owner"),
				"name":        "vault-owner",
				"displayName": "Vault1984 Owner",
			},
			"pubKeyCredParams": []map[string]any{
				{"type": "public-key", "alg": -7},   // ES256
				{"type": "public-key", "alg": -257}, // RS256
			},
			"authenticatorSelection": map[string]any{
				"residentKey":      "preferred",
				"userVerification": "required",
			},
			"extensions": map[string]any{
				"prf": map[string]any{},
			},
		},
	}
	JSONResponse(w, http.StatusOK, options)
}

// HandleWebAuthnRegisterComplete finishes WebAuthn registration.
func (h *Handlers) HandleWebAuthnRegisterComplete(w http.ResponseWriter, r *http.Request) {
	var req struct {
		CredID    string `json:"cred_id"`
		PublicKey []byte `json:"public_key"`
		PRFSalt  []byte `json:"prf_salt"`
		Name     string `json:"name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	// Decode the base64url credential ID from the browser
	credentialID, err := base64.RawURLEncoding.DecodeString(req.CredID)
	if err != nil {
		credentialID = []byte{}
	}

	cred := &lib.WebAuthnCredential{
		CredID:       lib.HexID(lib.NewID()),
		Name:         req.Name,
		PublicKey:    req.PublicKey,
		CredentialID: credentialID,
		PRFSalt:      req.PRFSalt,
	}
	if err := lib.StoreWebAuthnCredential(h.db(r), cred); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential")
		return
	}

	JSONResponse(w, http.StatusCreated, map[string]any{"status": "registered", "cred_id": cred.CredID})
}

// HandleWebAuthnAuthBegin starts WebAuthn authentication with PRF extension.
func (h *Handlers) HandleWebAuthnAuthBegin(w http.ResponseWriter, r *http.Request) {
	creds, err := lib.GetWebAuthnCredentials(h.db(r))
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get credentials")
		return
	}

	challenge := make([]byte, 32)
	rand.Read(challenge)

	var allowCreds []map[string]any
	var prfSalt []byte
	for _, c := range creds {
		allowCreds = append(allowCreds, map[string]any{
			"type": "public-key",
			"id":   c.CredID,
		})
		if len(c.PRFSalt) > 0 {
			prfSalt = c.PRFSalt
		}
	}

	prfExt := map[string]any{}
	if len(prfSalt) > 0 {
		prfExt["eval"] = map[string]any{
			"first": prfSalt,
		}
	}

	options := map[string]any{
		"publicKey": map[string]any{
			"challenge":        challenge,
			"allowCredentials": allowCreds,
			"userVerification": "required",
			"extensions": map[string]any{
				"prf": prfExt,
			},
		},
	}
	JSONResponse(w, http.StatusOK, options)
}

// HandleWebAuthnAuthComplete finishes WebAuthn authentication.
func (h *Handlers) HandleWebAuthnAuthComplete(w http.ResponseWriter, r *http.Request) {
	var req struct {
		CredID    lib.HexID `json:"cred_id"`
		SignCount int       `json:"sign_count"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	if err := lib.UpdateWebAuthnSignCount(h.db(r), int64(req.CredID), req.SignCount); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update sign count")
		return
	}

	JSONResponse(w, http.StatusOK, map[string]string{"status": "authenticated"})
}

// HandleListWebAuthnCredentials returns all registered WebAuthn credentials.
func (h *Handlers) HandleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) {
	creds, err := lib.GetWebAuthnCredentials(h.db(r))
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list credentials")
		return
	}
	if creds == nil {
		creds = []lib.WebAuthnCredential{}
	}
	JSONResponse(w, http.StatusOK, creds)
}

// HandleDeleteWebAuthnCredential removes a WebAuthn credential.
func (h *Handlers) HandleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) {
	id, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid credential ID")
		return
	}
	if err := lib.DeleteWebAuthnCredential(h.db(r), id); err != nil {
		if err == lib.ErrNotFound {
			ErrorResponse(w, http.StatusNotFound, "not_found", "Credential not found")
			return
		}
		ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete credential")
		return
	}
	JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// mcpFilterEntries filters entries based on scoped MCP token restrictions.
func mcpFilterEntries(entries []lib.Entry, token *lib.MCPToken) []lib.Entry {
	if token == nil || len(token.EntryIDs) == 0 {
		return entries
	}

	idSet := map[lib.HexID]bool{}
	for _, id := range token.EntryIDs {
		idSet[id] = true
	}

	var filtered []lib.Entry
	for _, e := range entries {
		if idSet[e.EntryID] {
			filtered = append(filtered, e)
		}
	}
	return filtered
}

func stripL2Fields(vd *lib.VaultData) {
	for i := range vd.Fields {
		if vd.Fields[i].L2 {
			vd.Fields[i].Value = "[REDACTED — not available to agents]"
		}
	}
}

// normalizeURLForDedup strips scheme differences, trailing slashes, and lowercases
// so that "http://x.com/" and "https://x.com" produce the same dedup key.
func normalizeURLForDedup(u string) string {
	u = strings.ToLower(u)
	u = strings.TrimPrefix(u, "https://")
	u = strings.TrimPrefix(u, "http://")
	u = strings.TrimRight(u, "/")
	return u
}

// importDedupKey builds a dedup key from a VaultData's first URL + username.
// For notes/entries without URLs or usernames, fall back to the title to avoid
// all notes colliding on the same empty key.
func importDedupKey(vd *lib.VaultData) string {
	var url, username string
	if len(vd.URLs) > 0 {
		url = normalizeURLForDedup(vd.URLs[0])
	}
	for _, f := range vd.Fields {
		lbl := strings.ToLower(f.Label)
		if lbl == "username" || lbl == "email" || lbl == "login" {
			username = strings.ToLower(f.Value)
			break
		}
	}
	key := url + "|" + username
	if key == "|" {
		// No URL and no username — use title to differentiate.
		key = "title:" + strings.ToLower(vd.Title)
	}
	return key
}

func extractDomain(urlStr string) string {
	// Simple domain extraction
	urlStr = strings.TrimPrefix(urlStr, "https://")
	urlStr = strings.TrimPrefix(urlStr, "http://")
	urlStr = strings.TrimPrefix(urlStr, "www.")
	if idx := strings.Index(urlStr, "/"); idx > 0 {
		urlStr = urlStr[:idx]
	}
	if idx := strings.Index(urlStr, ":"); idx > 0 {
		urlStr = urlStr[:idx]
	}
	return urlStr
}

func callLLM(cfg *lib.Config, system, user string) (string, error) {
	reqBody := map[string]any{
		"model": cfg.LLMModel,
		"messages": []map[string]string{
			{"role": "system", "content": system},
			{"role": "user", "content": user},
		},
		"max_tokens": 4096,
	}
	body, _ := json.Marshal(reqBody)

	req, _ := http.NewRequest("POST", cfg.LLMBaseURL+"/chat/completions", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+cfg.FireworksAPIKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	var result struct {
		Choices []struct {
			Message struct {
				Content string `json:"content"`
			} `json:"message"`
		} `json:"choices"`
		Error struct {
			Message string `json:"message"`
		} `json:"error"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", err
	}
	if result.Error.Message != "" {
		return "", fmt.Errorf("LLM error: %s", result.Error.Message)
	}
	if len(result.Choices) == 0 {
		return "", fmt.Errorf("no response from LLM")
	}
	return result.Choices[0].Message.Content, nil
}

// generateTOTPSecret generates a new TOTP secret.
func generateTOTPSecret() string {
	b := make([]byte, 20)
	rand.Read(b)
	return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
}

// GeoLookup returns the visitor's geolocation.
// If ?lat=X&lon=Y supplied (browser geo fallback), reverse-geocodes those coordinates via BigDataCloud.
// Otherwise geolocates the request IP; returns {"private":true} for LAN IPs.
func (h *Handlers) GeoLookup(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")

	// lat/lon path — browser geolocation fallback for LAN visitors
	if latStr := r.URL.Query().Get("lat"); latStr != "" {
		lonStr := r.URL.Query().Get("lon")
		latF, errLat := strconv.ParseFloat(latStr, 64)
		lonF, errLon := strconv.ParseFloat(lonStr, 64)
		if errLat != nil || errLon != nil {
			json.NewEncoder(w).Encode(map[string]any{"private": true})
			return
		}
		// Nominatim reverse geocode (OSM, free, no key)
		url := fmt.Sprintf("https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=%f&lon=%f", latF, lonF)
		client := &http.Client{Timeout: 5 * time.Second}
		req, _ := http.NewRequest("GET", url, nil)
		req.Header.Set("User-Agent", "vault1984/1.0 (https://vault1984.com)")
		resp, err := client.Do(req)
		if err == nil {
			defer resp.Body.Close()
			var raw struct {
				Address struct {
					City        string `json:"city"`
					Town        string `json:"town"`
					Village     string `json:"village"`
					State       string `json:"state"`
					Country     string `json:"country"`
					CountryCode string `json:"country_code"`
				} `json:"address"`
			}
			if json.NewDecoder(resp.Body).Decode(&raw) == nil {
				city := raw.Address.City
				if city == "" { city = raw.Address.Town }
				if city == "" { city = raw.Address.Village }
				cc := strings.ToUpper(raw.Address.CountryCode)
				json.NewEncoder(w).Encode(map[string]any{
					"city":         city,
					"region":       raw.Address.State,
					"country_name": raw.Address.Country,
					"country_code": cc,
					"latitude":     latF,
					"longitude":    lonF,
				})
				return
			}
		}
		// reverse-geocode failed — at least return coords
		json.NewEncoder(w).Encode(map[string]any{"latitude": latF, "longitude": lonF})
		return
	}

	// IP-based path
	ip := realIP(r)
	if isPrivateIP(ip) {
		json.NewEncoder(w).Encode(map[string]any{"private": true})
		return
	}
	resp, err := http.Get("http://ip-api.com/json/" + ip + "?fields=status,city,regionName,country,countryCode,lat,lon")
	if err != nil {
		json.NewEncoder(w).Encode(map[string]any{"private": true})
		return
	}
	defer resp.Body.Close()
	var raw map[string]any
	if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil || raw["status"] != "success" {
		json.NewEncoder(w).Encode(map[string]any{"private": true})
		return
	}
	json.NewEncoder(w).Encode(map[string]any{
		"city":         raw["city"],
		"region":       raw["regionName"],
		"country_name": raw["country"],
		"country_code": raw["countryCode"],
		"latitude":     raw["lat"],
		"longitude":    raw["lon"],
	})
}

func isPrivateIP(ip string) bool {
	parsed := net.ParseIP(ip)
	if parsed == nil { return true }
	private := []string{"10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","::1/128","fc00::/7"}
	for _, cidr := range private {
		_, block, _ := net.ParseCIDR(cidr)
		if block != nil && block.Contains(parsed) { return true }
	}
	return false
}

// --- Backup / Restore ---

// ListBackups returns all available backup files.
func (h *Handlers) ListBackups(w http.ResponseWriter, r *http.Request) {
	backups := lib.ListBackups(h.Cfg.DataDir)
	JSONResponse(w, http.StatusOK, backups)
}

// CreateBackup triggers an immediate backup.
func (h *Handlers) CreateBackup(w http.ResponseWriter, r *http.Request) {
	lib.RunBackups(h.Cfg.DataDir)
	backups := lib.ListBackups(h.Cfg.DataDir)
	JSONResponse(w, http.StatusOK, backups)
}

// RestoreBackup restores from a named backup file.
func (h *Handlers) RestoreBackup(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Name string `json:"name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
		ErrorResponse(w, http.StatusBadRequest, "invalid_request", "Backup name required")
		return
	}
	if err := lib.RestoreBackup(h.Cfg.DataDir, req.Name); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "restore_error", err.Error())
		return
	}
	JSONResponse(w, http.StatusOK, map[string]string{"status": "restored", "name": req.Name})
}

// ---------------------------------------------------------------------------
// Agent Management
// ---------------------------------------------------------------------------

// HandleCreateAgent creates a new agent.
// POST /api/agents
func (h *Handlers) HandleCreateAgent(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Name            string   `json:"name"`
		IPWhitelist     []string `json:"ip_whitelist"`
		RateLimitMinute int      `json:"rate_limit_minute"`
		RateLimitHour   int      `json:"rate_limit_hour"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}
	if req.Name == "" {
		ErrorResponse(w, http.StatusBadRequest, "missing_name", "Name is required")
		return
	}

	db := h.db(r)

	// Check for duplicate name
	existing, _ := lib.AgentGetByName(db, req.Name)
	if existing != nil {
		ErrorResponse(w, http.StatusConflict, "duplicate_name", "Agent with this name already exists")
		return
	}

	a := &lib.Agent{
		Name:            req.Name,
		IPWhitelist:     req.IPWhitelist,
		RateLimitMinute: req.RateLimitMinute,
		RateLimitHour:   req.RateLimitHour,
	}
	if err := lib.AgentCreate(db, a); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create agent")
		return
	}

	lib.AuditLog(db, &lib.AuditEvent{
		Action: "agent_create",
		Actor:  ActorFromContext(r.Context()),
		IPAddr: realIP(r),
		Title: a.Name,
	})

	JSONResponse(w, http.StatusCreated, a)
}

// HandleListAgents lists all agents.
// GET /api/agents
func (h *Handlers) HandleListAgents(w http.ResponseWriter, r *http.Request) {
	agents, err := lib.AgentList(h.db(r))
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list agents")
		return
	}
	if agents == nil {
		agents = []lib.Agent{}
	}
	JSONResponse(w, http.StatusOK, agents)
}

// HandleGetAgent returns a single agent.
// GET /api/agents/{id}
func (h *Handlers) HandleGetAgent(w http.ResponseWriter, r *http.Request) {
	agentID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
		return
	}
	a, err := lib.AgentGet(h.db(r), agentID)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
		return
	}
	if a == nil {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
		return
	}
	JSONResponse(w, http.StatusOK, a)
}

// HandleUpdateAgentWhitelist updates an agent's IP whitelist.
// PUT /api/agents/{id}/whitelist
func (h *Handlers) HandleUpdateAgentWhitelist(w http.ResponseWriter, r *http.Request) {
	agentID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
		return
	}

	var req struct {
		IPWhitelist []string `json:"ip_whitelist"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	db := h.db(r)
	a, err := lib.AgentGet(db, agentID)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
		return
	}
	if a == nil {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
		return
	}

	if err := lib.AgentUpdateWhitelist(db, agentID, req.IPWhitelist); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update whitelist")
		return
	}

	lib.AuditLog(db, &lib.AuditEvent{
		Action: "agent_update_whitelist",
		Actor:  ActorFromContext(r.Context()),
		IPAddr: realIP(r),
		Title: a.Name,
	})

	JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"})
}

// HandleUpdateAgentRateLimits updates an agent's rate limits.
// PUT /api/agents/{id}/rate-limits
func (h *Handlers) HandleUpdateAgentRateLimits(w http.ResponseWriter, r *http.Request) {
	agentID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
		return
	}

	var req struct {
		RateLimitMinute int `json:"rate_limit_minute"`
		RateLimitHour   int `json:"rate_limit_hour"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
		return
	}

	db := h.db(r)
	a, err := lib.AgentGet(db, agentID)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
		return
	}
	if a == nil {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
		return
	}

	if err := lib.AgentUpdateRateLimits(db, agentID, req.RateLimitMinute, req.RateLimitHour); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update rate limits")
		return
	}

	lib.AuditLog(db, &lib.AuditEvent{
		Action: "agent_update_rate_limits",
		Actor:  ActorFromContext(r.Context()),
		IPAddr: realIP(r),
		Title: a.Name,
	})

	JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"})
}

// HandleLockAgent manually locks an agent.
// POST /api/agents/{id}/lock
func (h *Handlers) HandleLockAgent(w http.ResponseWriter, r *http.Request) {
	agentID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
		return
	}

	db := h.db(r)
	a, err := lib.AgentGet(db, agentID)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
		return
	}
	if a == nil {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
		return
	}

	if err := lib.AgentUpdateStatus(db, agentID, lib.AgentStatusLocked, "manually locked"); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "lock_failed", "Failed to lock agent")
		return
	}

	lib.AuditLog(db, &lib.AuditEvent{
		Action: "agent_lock",
		Actor:  ActorFromContext(r.Context()),
		IPAddr: realIP(r),
		Title: a.Name,
	})

	JSONResponse(w, http.StatusOK, map[string]string{"status": "locked"})
}

// HandleUnlockAgent unlocks a locked agent.
// POST /api/agents/{id}/unlock
func (h *Handlers) HandleUnlockAgent(w http.ResponseWriter, r *http.Request) {
	agentID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
		return
	}

	db := h.db(r)
	a, err := lib.AgentGet(db, agentID)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
		return
	}
	if a == nil {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
		return
	}

	if err := lib.AgentUpdateStatus(db, agentID, lib.AgentStatusActive, ""); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "unlock_failed", "Failed to unlock agent")
		return
	}

	lib.AuditLog(db, &lib.AuditEvent{
		Action: "agent_unlock",
		Actor:  ActorFromContext(r.Context()),
		IPAddr: realIP(r),
		Title: a.Name,
	})

	JSONResponse(w, http.StatusOK, map[string]string{"status": "unlocked"})
}

// HandleRevokeAgent permanently revokes an agent.
// DELETE /api/agents/{id}
func (h *Handlers) HandleRevokeAgent(w http.ResponseWriter, r *http.Request) {
	agentID, err := lib.HexToID(chi.URLParam(r, "id"))
	if err != nil {
		ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
		return
	}

	db := h.db(r)
	a, err := lib.AgentGet(db, agentID)
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
		return
	}
	if a == nil {
		ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
		return
	}

	if err := lib.AgentDelete(db, agentID); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to revoke agent")
		return
	}

	lib.AuditLog(db, &lib.AuditEvent{
		Action: "agent_revoke",
		Actor:  ActorFromContext(r.Context()),
		IPAddr: realIP(r),
		Title: a.Name,
	})

	JSONResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
}

// ---------------------------------------------------------------------------
// Vault Lock
// ---------------------------------------------------------------------------

// HandleVaultLockStatus returns the vault lock state.
// GET /api/vault-lock
func (h *Handlers) HandleVaultLockStatus(w http.ResponseWriter, r *http.Request) {
	vl, err := lib.VaultLockGet(h.db(r))
	if err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get vault lock status")
		return
	}
	JSONResponse(w, http.StatusOK, vl)
}

// HandleVaultUnlock unlocks the vault.
// POST /api/vault-unlock
func (h *Handlers) HandleVaultUnlock(w http.ResponseWriter, r *http.Request) {
	db := h.db(r)
	if err := lib.VaultLockSet(db, false, ""); err != nil {
		ErrorResponse(w, http.StatusInternalServerError, "unlock_failed", "Failed to unlock vault")
		return
	}

	lib.AuditLog(db, &lib.AuditEvent{
		Action: "vault_unlock",
		Actor:  ActorFromContext(r.Context()),
		IPAddr: realIP(r),
	})

	JSONResponse(w, http.StatusOK, map[string]string{"status": "unlocked"})
}
