package persistence

import (
	"compress/gzip"
	"encoding/gob"
	"fmt"
	"os"
	"path/filepath"
	"time"
)

const (
	notesFile   = "notes.gob.gz"
	maxNotesCap = 5000
)

// NoteEntry is a single session-memory note. Notes are not graph
// nodes; they live alongside the graph as a separate, persistent
// side-store. A note is created via the `save_note` tool, surfaced
// via `query_notes`, and folded into the per-session digest emitted
// by `distill_session`.
//
// Auto-links capture the symbol IDs the agent (or the auto-linker)
// determined to be referenced by the note body. Because they are
// stored explicitly, queries by symbol stay O(notes) without
// re-tokenising every body on every call.
type NoteEntry struct {
	ID          string
	Timestamp   time.Time
	UpdatedAt   time.Time
	SessionID   string   // MCP session that created the note ("" for shared/embedded session)
	ClientName  string   // MCP clientInfo.name at create time (claude-code / cursor / ...)
	Body        string   // free-form text the agent wrote
	SymbolID    string   // primary attached symbol (optional)
	FilePath    string   // primary attached file (optional)
	RepoPrefix  string   // repo prefix derived from session scope or attached symbol/file
	WorkspaceID string   // workspace boundary; queries scope by this
	ProjectID   string   // project sub-boundary
	Tags        []string // free-form labels — "decision", "bug", "todo", ...
	AutoLinks   []string // symbol IDs referenced by the body (auto-detected + explicit links)
	Pinned      bool     // pinned notes are never evicted by the cap
}

// NoteStore is the persisted shape: a versioned, repo-scoped list of
// entries. Same persistence shape as FeedbackStore so the cache
// directory layout stays consistent.
type NoteStore struct {
	Version  string
	RepoPath string
	Entries  []NoteEntry
}

// NotesDir resolves the cache directory holding the notes file for
// the given repo. Mirrors FeedbackDir so the two side-stores share
// a per-repo cache subdirectory.
func NotesDir(cacheDir, repoPath string) string {
	return filepath.Join(cacheDir, RepoCacheKey(repoPath))
}

// LoadNotes reads a note store from disk. Returns an empty store
// when the file does not exist (cold start is normal).
func LoadNotes(dir string) (*NoteStore, error) {
	path := filepath.Join(dir, notesFile)
	f, err := os.Open(path)
	if err != nil {
		if os.IsNotExist(err) {
			return &NoteStore{}, nil
		}
		return nil, fmt.Errorf("persistence: open notes: %w", err)
	}
	defer func() { _ = f.Close() }()

	gz, err := gzip.NewReader(f)
	if err != nil {
		return nil, fmt.Errorf("persistence: gzip reader notes: %w", err)
	}
	defer func() { _ = gz.Close() }()

	var store NoteStore
	if err := gob.NewDecoder(gz).Decode(&store); err != nil {
		return nil, fmt.Errorf("persistence: gob decode notes: %w", err)
	}
	return &store, nil
}

// SaveNotes writes the store to disk with gob+gzip. Trimming
// honours pinned notes: when the entry count exceeds maxNotesCap,
// the oldest non-pinned entries are dropped first.
func SaveNotes(dir string, store *NoteStore) error {
	if len(store.Entries) > maxNotesCap {
		store.Entries = trimNotes(store.Entries, maxNotesCap)
	}

	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("persistence: mkdir notes: %w", err)
	}

	path := filepath.Join(dir, notesFile)
	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("persistence: create notes: %w", err)
	}

	gz := gzip.NewWriter(f)
	enc := gob.NewEncoder(gz)

	if err := enc.Encode(store); err != nil {
		_ = gz.Close()
		_ = f.Close()
		return fmt.Errorf("persistence: gob encode notes: %w", err)
	}

	if err := gz.Close(); err != nil {
		_ = f.Close()
		return fmt.Errorf("persistence: gzip close notes: %w", err)
	}

	return f.Close()
}

// trimNotes drops the oldest non-pinned entries until len(out) <= cap.
// Order is preserved so callers iterate chronologically. If pinned
// entries alone exceed cap, every pinned entry is still retained
// — the cap is a soft ceiling for the unpinned tail.
func trimNotes(in []NoteEntry, cap int) []NoteEntry {
	if len(in) <= cap {
		return in
	}
	excess := len(in) - cap

	// Walk forward dropping non-pinned entries until we have shed
	// `excess` of them, then return the tail.
	out := make([]NoteEntry, 0, cap)
	dropped := 0
	for _, e := range in {
		if dropped < excess && !e.Pinned {
			dropped++
			continue
		}
		out = append(out, e)
	}
	return out
}
