package daemon

import (
	"errors"
	"os"
	"regexp"
	"sort"
	"sync"
	"time"
)

// DefaultOverlayIdleTTL is the default per-session idle expiry for
// editor-buffer overlays. Raised from the original 5-minute draft to
// 30 minutes after field feedback: realistic coding sessions
// (deliberation, debugger pauses, IDE refactor wizards) commonly
// exceed five minutes without producing a Push, and the read-time
// LastUsed bump in SnapshotFor already keeps the session alive for
// any tool call. Thirty minutes is comfortably above typical
// inactivity gaps and well below the point where a forgotten
// disconnected client would meaningfully pin memory.
const DefaultOverlayIdleTTL = 30 * time.Minute

// MainBranchName is the implicit base branch every session starts on.
// All existing overlay tools (push / list / delete / drop /
// compare_with_overlay / preview_edit / simulate_chain) operate
// against the active branch — and unless the caller explicitly
// switches, that branch is `main`. Existing callers that never touch
// the branch tools see one branch with this name and never need to
// know about branches at all.
const MainBranchName = "main"

// branchNamePattern bounds branch names to a small, predictable
// alphabet. Stays well clear of shell-meta and path-separator
// characters so a branch name can safely embed in tool responses,
// log lines, or future on-disk representations.
var branchNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$`)

// OverlayIdleTTLFromEnv resolves the idle-TTL setting that
// `gortex server` / `gortex daemon` should construct their
// OverlayManager with. Precedence:
//
//  1. Caller-supplied override (non-zero).
//  2. `GORTEX_OVERLAY_IDLE_TTL` env var, parsed by time.ParseDuration
//     ("30m", "1h", "45s", etc.).
//  3. DefaultOverlayIdleTTL.
//
// A negative parse result clamps to 0 (which means "no expiry" —
// useful only for tests). Garbage env values fall back to the
// default; we deliberately don't fail startup over a typo'd duration.
func OverlayIdleTTLFromEnv(override time.Duration) time.Duration {
	if override > 0 {
		return override
	}
	if raw := os.Getenv("GORTEX_OVERLAY_IDLE_TTL"); raw != "" {
		if d, err := time.ParseDuration(raw); err == nil {
			if d < 0 {
				d = 0
			}
			return d
		}
	}
	return DefaultOverlayIdleTTL
}

// OverlayFile is one editor-buffer override pushed by an MCP client.
// The daemon (or a remote graph service over the gateway) merges
// these on top of the base graph view for the duration of a session.
// Iteration 1 only models text files; binary overlays would need a
// different shape and are not in scope.
type OverlayFile struct {
	// Path is repo-relative when WorkspaceID is set, absolute
	// otherwise. The graph service maps it onto the repo root via
	// its ConfigManager.
	Path string `json:"path"`
	// Content is the in-editor text. Empty means "deletion overlay"
	// — the client wants the daemon to act as if the file isn't on
	// disk, even if it actually is. The daemon distinguishes this
	// from a real empty file by only honouring deletions when the
	// session declares them via OverlayPush(..., Deleted: true).
	Content string `json:"content"`
	// BaseSHA is the file's git blob SHA at the time the editor
	// opened it. Used by the daemon's drift-detection: if the file
	// on disk now hashes to a different SHA, the overlay is stale
	// and the daemon refuses to merge it (returns ErrOverlayDrift).
	// Empty disables drift-detection — useful for editor-buffer
	// states that exist before any save.
	BaseSHA string `json:"base_sha,omitempty"`
	// Deleted, when true, marks the overlay as a tombstone (see
	// Content above). Mutually exclusive with non-empty Content.
	Deleted bool `json:"deleted,omitempty"`
}

// overlayBranch is one named speculative state owned by an
// OverlaySession. A session always carries the implicit `main`
// branch; `overlay_fork` materialises additional branches by deep-
// copying the source branch's file map. Branches share nothing
// mutable after the fork instant — every Push / Delete / Drop
// targets exactly one branch and leaves the rest untouched.
type overlayBranch struct {
	Name    string
	Parent  string // empty for the implicit `main` branch
	Created time.Time
	files   map[string]OverlayFile // path → overlay
}

// OverlaySession holds one client's pushed overlays for the duration
// of an MCP session. Sessions auto-expire after IdleTTL of inactivity
// so a crashed client doesn't leak memory in the daemon. Each session
// carries a set of named branches (always at least the implicit
// `main` branch) and exactly one is active at any time; all existing
// overlay queries route to that active branch.
type OverlaySession struct {
	ID          string
	WorkspaceID string
	Created     time.Time
	LastUsed    time.Time
	branches    map[string]*overlayBranch
	active      string
}

// activeBranch returns the active branch struct. Callers always hold
// the manager's mutex when invoking this — branches mutate exclusively
// under it.
func (s *OverlaySession) activeBranch() *overlayBranch {
	if s == nil {
		return nil
	}
	if br, ok := s.branches[s.active]; ok {
		return br
	}
	// Defensive fallback: surfaces an empty `main` branch if the
	// active pointer somehow drifted. RegisterWithID / SwitchBranch
	// keep `active` in sync with branches; this guard is belt-and-
	// braces for future refactors.
	if br, ok := s.branches[MainBranchName]; ok {
		s.active = MainBranchName
		return br
	}
	return nil
}

// OverlayManager manages the per-session overlay map for the daemon.
// Goroutine-safe; callers can register, push, and delete from any
// goroutine. A single janitor goroutine sweeps idle sessions.
type OverlayManager struct {
	mu       sync.RWMutex
	sessions map[string]*OverlaySession
	idleTTL  time.Duration
}

// ErrSessionNotFound is returned by OverlayManager methods that
// reference an unknown session ID. The daemon translates this to
// HTTP 404 on `/v1/overlay/<id>/...` endpoints.
var ErrSessionNotFound = errors.New("overlay session not found")

// ErrOverlayDrift is returned by OverlayPush when the supplied
// BaseSHA disagrees with the file's current on-disk SHA. The client
// is expected to re-read the file and resubmit a fresh overlay; the
// daemon refuses to fold a known-stale overlay into queries because
// merge artefacts (lines moved by a sibling tool's edit) would
// surface as wrong-line errors that look like graph bugs.
var ErrOverlayDrift = errors.New("overlay base SHA mismatch — re-read and resubmit")

// ErrBranchNotFound is returned by branch tools when the named
// branch does not exist on the session. The MCP surface translates
// this into a structured tool error with the branch name embedded.
var ErrBranchNotFound = errors.New("overlay branch not found")

// ErrBranchExists is returned by Fork when the destination name is
// already taken. Branch names are unique per session.
var ErrBranchExists = errors.New("overlay branch already exists")

// ErrInvalidBranchName is returned when the caller passes a name
// that doesn't match branchNamePattern. The MCP surface echoes the
// pattern back so callers can correct the request locally.
var ErrInvalidBranchName = errors.New("invalid overlay branch name (alphanumeric + dash/underscore, max 64 chars)")

// ErrCannotDropActiveBranch is returned by DropBranch when the
// caller targets the currently active branch. The caller must
// SwitchBranch off it first.
var ErrCannotDropActiveBranch = errors.New("cannot drop the active overlay branch — switch first")

// ErrCannotDropMainBranch refuses to delete the implicit `main`
// branch. The session's base must always exist; dropping the whole
// session is the right move when the caller wants to walk away.
var ErrCannotDropMainBranch = errors.New("cannot drop the implicit main overlay branch — drop the session instead")

// ErrMergeConflict signals that a non-forced merge tried to fold
// branches that disagree on at least one file's content. The MCP
// surface returns the conflicting paths to the caller so they can
// resolve and retry with force:true.
var ErrMergeConflict = errors.New("overlay merge conflict — pass force:true to override (last-writer-wins)")

// NewOverlayManager creates a manager with the given idle TTL. ttl
// <= 0 disables expiry (useful for tests that want deterministic
// session behaviour).
func NewOverlayManager(idleTTL time.Duration) *OverlayManager {
	return &OverlayManager{
		sessions: make(map[string]*OverlaySession),
		idleTTL:  idleTTL,
	}
}

// Register starts a new session and returns its ID. The workspace
// slug is captured at register time; later pushes that target a
// different workspace are rejected (one session = one workspace,
// per the overlay model).
func (m *OverlayManager) Register(workspaceID string) string {
	id := newSessionID()
	_ = m.RegisterWithID(id, workspaceID)
	return id
}

// ErrSessionExists is returned by RegisterWithID when the caller-supplied
// session ID is already known. The MCP-side overlay tools rely on this
// error to detect "register-called-twice" races; HTTP callers don't see
// it because Register generates fresh IDs.
var ErrSessionExists = errors.New("overlay session already exists")

// RegisterWithID registers a session under a caller-chosen ID. This is
// the path the MCP `overlay_register` tool takes — it binds the overlay
// session to the MCP session ID so the query path can find the overlay
// snapshot from the request context without an extra lookup. The HTTP
// register handler also routes through here when its body includes an
// explicit `session_id`.
//
// Returns ErrSessionExists when the ID is already in use. Idempotent
// re-registration (same workspaceID) is treated as a no-op: the client
// may safely retry register without first checking.
func (m *OverlayManager) RegisterWithID(sessionID, workspaceID string) error {
	if sessionID == "" {
		return errors.New("overlay session id is required")
	}
	now := time.Now()
	m.mu.Lock()
	defer m.mu.Unlock()
	if existing, ok := m.sessions[sessionID]; ok {
		if existing.WorkspaceID == workspaceID {
			existing.LastUsed = now
			return nil
		}
		return ErrSessionExists
	}
	m.sessions[sessionID] = &OverlaySession{
		ID:          sessionID,
		WorkspaceID: workspaceID,
		Created:     now,
		LastUsed:    now,
		branches: map[string]*overlayBranch{
			MainBranchName: {
				Name:    MainBranchName,
				Created: now,
				files:   make(map[string]OverlayFile),
			},
		},
		active: MainBranchName,
	}
	return nil
}

// Has reports whether a session is currently registered. Used by the
// MCP tool dispatcher to decide whether to skip the per-request apply
// pass (no session → no work). Cheap O(1) read under the read lock.
func (m *OverlayManager) Has(sessionID string) bool {
	if m == nil || sessionID == "" {
		return false
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	_, ok := m.sessions[sessionID]
	return ok
}

// FileCount returns the number of overlay files attached to a session's
// active branch, 0 if the session is unknown. Cheap fast-path: lets the
// dispatcher skip the apply pass when a session is registered but its
// active branch is empty.
func (m *OverlayManager) FileCount(sessionID string) int {
	if m == nil || sessionID == "" {
		return 0
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return 0
	}
	br := sess.activeBranch()
	if br == nil {
		return 0
	}
	return len(br.files)
}

// Touch refreshes a session's idle timer without altering its overlay
// files. Called by the MCP `overlay_keepalive` tool so an editor can
// explicitly extend the lease without re-pushing buffer content.
// Returns ErrSessionNotFound when the session doesn't exist so the
// keepalive tool can surface "session lost; please re-register".
func (m *OverlayManager) Touch(sessionID string) error {
	if m == nil {
		return ErrSessionNotFound
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return ErrSessionNotFound
	}
	sess.LastUsed = time.Now()
	return nil
}

// IdleTTL returns the configured idle expiry duration. Exposed so the
// overlay_list tool can compute and surface an `expires_at` hint to
// editor extensions that want to schedule a keepalive proactively.
// Zero means "no expiry" (test-mode).
func (m *OverlayManager) IdleTTL() time.Duration {
	if m == nil {
		return 0
	}
	return m.idleTTL
}

// SessionStatus is the per-session liveness snapshot reported through
// overlay_list. Callers compare `IdleSeconds` to `IdleTTLSeconds` to
// decide when to push a keepalive; `ExpiresAt` (RFC3339) is the
// concrete wall-clock instant the janitor would reap the session if
// no further activity arrived. ActiveBranch carries the session's
// currently-active branch name (always non-empty for a live session).
type SessionStatus struct {
	WorkspaceID    string
	Created        time.Time
	LastUsed       time.Time
	IdleSeconds    float64
	IdleTTLSeconds float64
	ExpiresAt      time.Time // zero when idleTTL <= 0
	ActiveBranch   string
}

// StatusFor returns liveness metadata for a session without touching
// LastUsed (unlike SnapshotFor, which is a read-with-bump). Used by
// overlay_list to render expiry hints without resetting the timer
// every time the editor polls.
func (m *OverlayManager) StatusFor(sessionID string) (SessionStatus, error) {
	if m == nil {
		return SessionStatus{}, ErrSessionNotFound
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return SessionStatus{}, ErrSessionNotFound
	}
	st := SessionStatus{
		WorkspaceID:    sess.WorkspaceID,
		Created:        sess.Created,
		LastUsed:       sess.LastUsed,
		IdleSeconds:    time.Since(sess.LastUsed).Seconds(),
		IdleTTLSeconds: m.idleTTL.Seconds(),
		ActiveBranch:   sess.active,
	}
	if m.idleTTL > 0 {
		st.ExpiresAt = sess.LastUsed.Add(m.idleTTL)
	}
	return st, nil
}

// SnapshotFor returns the overlay files for a session's active branch
// in a stable, path-sorted order along with the workspace slug
// captured at register time. Returns ErrSessionNotFound when the
// session doesn't exist. The returned slice never aliases the
// manager's internal map; callers can mutate it freely.
//
// This is the preferred read API for the query path: the deterministic
// ordering means two overlay-active requests with the same overlay set
// touch the same paths in the same order, which simplifies test
// assertions and makes drift errors point at the same path on retry.
func (m *OverlayManager) SnapshotFor(sessionID string) (workspace string, files []OverlayFile, err error) {
	if m == nil {
		return "", nil, ErrSessionNotFound
	}
	// Promoted to write lock so we can refresh LastUsed alongside
	// the snapshot copy: every tool-call view-build flows through
	// here, and that activity must reset the idle timer. Without
	// this, a session that only queries (no further Push) would
	// trip the TTL while in active use. The cost is one extra
	// mutex promotion per overlay-active tool call — negligible
	// against the parse work the view builder is about to do.
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return "", nil, ErrSessionNotFound
	}
	sess.LastUsed = time.Now()
	br := sess.activeBranch()
	if br == nil {
		return sess.WorkspaceID, nil, nil
	}
	out := make([]OverlayFile, 0, len(br.files))
	for _, f := range br.files {
		out = append(out, f)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
	return sess.WorkspaceID, out, nil
}

// Push attaches one overlay file to a session's active branch.
// Workspace mismatch (the session was registered for workspace X but
// the push targets Y) returns an error: a session is supposed to be
// a coherent view over one workspace's repos.
//
// driftCheck is a callback the manager invokes to verify BaseSHA
// against the on-disk file. The daemon supplies it; tests can pass
// nil to skip the check. If driftCheck returns false and overlay
// has a non-empty BaseSHA, Push fails with ErrOverlayDrift.
func (m *OverlayManager) Push(sessionID string, overlay OverlayFile, driftCheck func(path, sha string) bool) error {
	if overlay.Path == "" {
		return errors.New("overlay path is required")
	}
	if overlay.Deleted && overlay.Content != "" {
		return errors.New("overlay cannot be both deleted and have content")
	}
	if overlay.BaseSHA != "" && driftCheck != nil {
		if !driftCheck(overlay.Path, overlay.BaseSHA) {
			return ErrOverlayDrift
		}
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return ErrSessionNotFound
	}
	br := sess.activeBranch()
	if br == nil {
		return ErrSessionNotFound
	}
	br.files[overlay.Path] = overlay
	sess.LastUsed = time.Now()
	return nil
}

// Delete removes one overlay file from a session's active branch by
// path. Returns ErrSessionNotFound when the session doesn't exist.
func (m *OverlayManager) Delete(sessionID, path string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return ErrSessionNotFound
	}
	br := sess.activeBranch()
	if br == nil {
		return ErrSessionNotFound
	}
	delete(br.files, path)
	sess.LastUsed = time.Now()
	return nil
}

// Drop terminates the session and discards every branch and overlay
// it held. Idempotent — dropping an unknown session is a no-op.
func (m *OverlayManager) Drop(sessionID string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	delete(m.sessions, sessionID)
}

// Files returns a snapshot of every overlay attached to a session's
// active branch (no live aliasing — the returned map can be mutated
// freely). ErrSessionNotFound when the session doesn't exist.
func (m *OverlayManager) Files(sessionID string) (map[string]OverlayFile, error) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return nil, ErrSessionNotFound
	}
	br := sess.activeBranch()
	if br == nil {
		return map[string]OverlayFile{}, nil
	}
	out := make(map[string]OverlayFile, len(br.files))
	for k, v := range br.files {
		out[k] = v
	}
	return out, nil
}

// SessionWorkspace returns the workspace slug captured at Register.
// ErrSessionNotFound when the session doesn't exist.
func (m *OverlayManager) SessionWorkspace(sessionID string) (string, error) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return "", ErrSessionNotFound
	}
	return sess.WorkspaceID, nil
}

// SweepIdle drops sessions whose LastUsed is older than IdleTTL.
// Returns the count of dropped sessions for telemetry. Safe to call
// from a single janitor goroutine on a ticker.
func (m *OverlayManager) SweepIdle() int {
	if m.idleTTL <= 0 {
		return 0
	}
	cutoff := time.Now().Add(-m.idleTTL)
	m.mu.Lock()
	defer m.mu.Unlock()
	dropped := 0
	for id, sess := range m.sessions {
		if sess.LastUsed.Before(cutoff) {
			delete(m.sessions, id)
			dropped++
		}
	}
	return dropped
}

// ---------------------------------------------------------------------------
// Branching surface: fork, list, switch, merge, drop_branch.
// ---------------------------------------------------------------------------

// ValidateBranchName returns ErrInvalidBranchName when the supplied
// name does not satisfy the [A-Za-z0-9][A-Za-z0-9_-]{0,63} pattern.
// Exposed so the MCP layer can fail fast with a precise message
// before locking the manager.
func ValidateBranchName(name string) error {
	if !branchNamePattern.MatchString(name) {
		return ErrInvalidBranchName
	}
	return nil
}

// BranchInfo is the per-branch row returned by Branches.
type BranchInfo struct {
	Name         string
	Active       bool
	FileCount    int
	BaseSHACount int
	Parent       string
	Created      time.Time
}

// Branches lists every branch a session holds, including the implicit
// `main` branch. The returned slice is sorted by name (with the
// active branch always first in the response when the caller wants
// to surface it). Returns ErrSessionNotFound when the session doesn't
// exist.
func (m *OverlayManager) Branches(sessionID string) ([]BranchInfo, error) {
	if m == nil {
		return nil, ErrSessionNotFound
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return nil, ErrSessionNotFound
	}
	out := make([]BranchInfo, 0, len(sess.branches))
	for name, br := range sess.branches {
		baseShas := 0
		for _, f := range br.files {
			if f.BaseSHA != "" {
				baseShas++
			}
		}
		out = append(out, BranchInfo{
			Name:         name,
			Active:       name == sess.active,
			FileCount:    len(br.files),
			BaseSHACount: baseShas,
			Parent:       br.Parent,
			Created:      br.Created,
		})
	}
	sort.Slice(out, func(i, j int) bool {
		// Active branch first, then alphabetical — keeps the JSON
		// payload deterministic AND foregrounds the most relevant
		// entry for diagnostic dumps.
		if out[i].Active != out[j].Active {
			return out[i].Active
		}
		return out[i].Name < out[j].Name
	})
	return out, nil
}

// ActiveBranch returns the name of the session's active branch.
// Returns ErrSessionNotFound when the session doesn't exist.
func (m *OverlayManager) ActiveBranch(sessionID string) (string, error) {
	if m == nil {
		return "", ErrSessionNotFound
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return "", ErrSessionNotFound
	}
	return sess.active, nil
}

// ForkOptions controls the Fork call. Name is required; From defaults
// to the session's active branch; Activate makes the new branch the
// session's active branch (otherwise the caller must SwitchBranch
// explicitly to use it).
type ForkOptions struct {
	Name     string
	From     string // empty → session's active branch
	Activate bool
}

// ForkResult carries the post-fork state so the MCP surface can echo
// it back to the caller without a separate Branches call.
type ForkResult struct {
	Branch    string
	Parent    string
	FileCount int
	Active    bool
}

// Fork clones the source branch into a new named branch on the same
// session. The new branch starts with a deep copy of the source's
// file map; no shared mutable state remains after the fork instant.
// Returns ErrBranchExists when the destination name is already taken.
func (m *OverlayManager) Fork(sessionID string, opts ForkOptions) (ForkResult, error) {
	if m == nil {
		return ForkResult{}, ErrSessionNotFound
	}
	if err := ValidateBranchName(opts.Name); err != nil {
		return ForkResult{}, err
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return ForkResult{}, ErrSessionNotFound
	}
	if _, ok := sess.branches[opts.Name]; ok {
		return ForkResult{}, ErrBranchExists
	}
	source := opts.From
	if source == "" {
		source = sess.active
	}
	src, ok := sess.branches[source]
	if !ok {
		return ForkResult{}, ErrBranchNotFound
	}
	cloneFiles := make(map[string]OverlayFile, len(src.files))
	for k, v := range src.files {
		cloneFiles[k] = v
	}
	now := time.Now()
	sess.branches[opts.Name] = &overlayBranch{
		Name:    opts.Name,
		Parent:  source,
		Created: now,
		files:   cloneFiles,
	}
	if opts.Activate {
		sess.active = opts.Name
	}
	sess.LastUsed = now
	return ForkResult{
		Branch:    opts.Name,
		Parent:    source,
		FileCount: len(cloneFiles),
		Active:    sess.active == opts.Name,
	}, nil
}

// SwitchBranch flips the session's active branch. Returns
// ErrBranchNotFound when the name is unknown.
func (m *OverlayManager) SwitchBranch(sessionID, name string) error {
	if m == nil {
		return ErrSessionNotFound
	}
	if err := ValidateBranchName(name); err != nil {
		return err
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return ErrSessionNotFound
	}
	if _, ok := sess.branches[name]; !ok {
		return ErrBranchNotFound
	}
	sess.active = name
	sess.LastUsed = time.Now()
	return nil
}

// DropBranch deletes a named branch. Refuses to drop the active
// branch (caller must SwitchBranch first) and refuses to drop the
// implicit `main` branch (which would leave the session in an
// inconsistent state — dropping the whole session is the right move
// when the caller wants to walk away).
func (m *OverlayManager) DropBranch(sessionID, name string) error {
	if m == nil {
		return ErrSessionNotFound
	}
	if name == MainBranchName {
		return ErrCannotDropMainBranch
	}
	if err := ValidateBranchName(name); err != nil {
		return err
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return ErrSessionNotFound
	}
	if name == sess.active {
		return ErrCannotDropActiveBranch
	}
	if _, ok := sess.branches[name]; !ok {
		return ErrBranchNotFound
	}
	delete(sess.branches, name)
	sess.LastUsed = time.Now()
	return nil
}

// MergeOptions controls MergeBranches. From and Into name the source
// and destination branches; ToDisk redirects the merge target from
// `Into` to the filesystem (writing each From file with the supplied
// writer callback). Force flips conflict policy from "refuse" to
// "last-writer-wins" — without it, any path that exists in both
// branches with different content blocks the merge.
type MergeOptions struct {
	From   string
	Into   string
	ToDisk bool
	Force  bool
}

// DiskWriteFn is the callback MergeBranches invokes for each From
// file when ToDisk is true. The implementer is responsible for the
// drift check (re-hash the on-disk content, compare to BaseSHA,
// return ErrOverlayDrift on mismatch) and the atomic write. Keeping
// the daemon free of filesystem and SHA-hashing concerns avoids a
// circular dependency on `internal/mcp` helpers; the MCP layer
// injects its existing `gitBlobSHA` + `agents.AtomicWriteFile`
// helpers as the writer.
type DiskWriteFn func(file OverlayFile) error

// MergeResult is the structured outcome of MergeBranches. The MCP
// surface echoes it back to the caller; the `Conflicts` slice
// carries the paths that disagreed when Force was false (the merge
// aborted) or that were last-writer-wins-resolved when Force was true.
type MergeResult struct {
	Merged          int
	Conflicts       []string
	DroppedBranch   bool
	ResolvedByForce bool
}

// MergeBranches folds opts.From into opts.Into (or onto disk when
// opts.ToDisk is true). Conflict policy: a "conflict" is a path that
// exists in both branches with different content/deleted/baseSha
// tuples. Without Force, conflicts abort the merge and the result
// reports the conflicting paths. With Force, conflicts are
// last-writer-wins: opts.From wins (consistent with the source-
// branch-driven model) and the response notes the resolution.
//
// When ToDisk is true, opts.From is written to the filesystem via the
// caller-supplied DiskWriteFn (which is expected to enforce the
// BaseSHA drift guard) and opts.From is dropped from the session on
// success. ToDisk + Force is meaningful: with drift-check enforcement
// inside the writer, force only affects branch-vs-branch conflict
// resolution; the writer itself is the disk-drift authority.
func (m *OverlayManager) MergeBranches(sessionID string, opts MergeOptions, write DiskWriteFn) (MergeResult, error) {
	if m == nil {
		return MergeResult{}, ErrSessionNotFound
	}
	if opts.From == "" {
		return MergeResult{}, errors.New("merge: from branch is required")
	}
	if err := ValidateBranchName(opts.From); err != nil {
		return MergeResult{}, err
	}
	if !opts.ToDisk {
		into := opts.Into
		if into == "" {
			into = MainBranchName
		}
		if err := ValidateBranchName(into); err != nil {
			return MergeResult{}, err
		}
		opts.Into = into
		if opts.From == opts.Into {
			return MergeResult{}, errors.New("merge: from and into are the same branch")
		}
	}

	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return MergeResult{}, ErrSessionNotFound
	}
	src, ok := sess.branches[opts.From]
	if !ok {
		return MergeResult{}, ErrBranchNotFound
	}

	if opts.ToDisk {
		if write == nil {
			return MergeResult{}, errors.New("merge: disk write callback is required for to_disk merges")
		}
		// Iterate paths in deterministic order so a drift error
		// always points at the same path on retry.
		paths := make([]string, 0, len(src.files))
		for p := range src.files {
			paths = append(paths, p)
		}
		sort.Strings(paths)
		merged := 0
		for _, p := range paths {
			f := src.files[p]
			if err := write(f); err != nil {
				// Surface drift unchanged so callers can pattern-
				// match the existing ErrOverlayDrift string.
				return MergeResult{Merged: merged}, err
			}
			merged++
		}
		delete(sess.branches, opts.From)
		if sess.active == opts.From {
			sess.active = MainBranchName
		}
		sess.LastUsed = time.Now()
		return MergeResult{Merged: merged, DroppedBranch: true}, nil
	}

	dst, ok := sess.branches[opts.Into]
	if !ok {
		return MergeResult{}, ErrBranchNotFound
	}

	// Walk From in deterministic order so the conflict / merge
	// list and the response are stable across runs.
	paths := make([]string, 0, len(src.files))
	for p := range src.files {
		paths = append(paths, p)
	}
	sort.Strings(paths)

	var conflicts []string
	for _, p := range paths {
		fromFile := src.files[p]
		if existing, ok := dst.files[p]; ok && !overlayFilesEqual(existing, fromFile) {
			conflicts = append(conflicts, p)
		}
	}
	if len(conflicts) > 0 && !opts.Force {
		return MergeResult{Conflicts: conflicts}, ErrMergeConflict
	}

	merged := 0
	for _, p := range paths {
		fromFile := src.files[p]
		dst.files[p] = fromFile
		merged++
	}
	sess.LastUsed = time.Now()
	return MergeResult{
		Merged:          merged,
		Conflicts:       conflicts,
		ResolvedByForce: len(conflicts) > 0,
	}, nil
}

// overlayFilesEqual is the conflict-policy primitive: two files
// disagree when any user-visible field differs. BaseSHA is included
// because two branches that opened the same disk file at different
// times may have stale SHAs that, if silently merged, would carry
// a known-stale baseline into the destination.
func overlayFilesEqual(a, b OverlayFile) bool {
	return a.Path == b.Path &&
		a.Content == b.Content &&
		a.BaseSHA == b.BaseSHA &&
		a.Deleted == b.Deleted
}

// PushToBranch attaches one overlay file to a *specific* branch
// without changing the session's active branch. The single-branch
// Push API operates against the active branch (backward compat);
// this variant is for callers — primarily test harnesses and
// future concurrent agents — that need to mutate sibling branches
// without disturbing the active pointer. Conflict semantics with
// the active-branch view are identical: a push merely overwrites
// the per-branch file map under the manager's mutex.
func (m *OverlayManager) PushToBranch(sessionID, branchName string, overlay OverlayFile, driftCheck func(path, sha string) bool) error {
	if overlay.Path == "" {
		return errors.New("overlay path is required")
	}
	if overlay.Deleted && overlay.Content != "" {
		return errors.New("overlay cannot be both deleted and have content")
	}
	if overlay.BaseSHA != "" && driftCheck != nil {
		if !driftCheck(overlay.Path, overlay.BaseSHA) {
			return ErrOverlayDrift
		}
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return ErrSessionNotFound
	}
	br, ok := sess.branches[branchName]
	if !ok {
		return ErrBranchNotFound
	}
	br.files[overlay.Path] = overlay
	sess.LastUsed = time.Now()
	return nil
}

// DeleteFromBranch removes one overlay file from a specific branch.
// Companion to PushToBranch; does not change the active pointer.
func (m *OverlayManager) DeleteFromBranch(sessionID, branchName, path string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return ErrSessionNotFound
	}
	br, ok := sess.branches[branchName]
	if !ok {
		return ErrBranchNotFound
	}
	delete(br.files, path)
	sess.LastUsed = time.Now()
	return nil
}

// FilesForBranch returns the file map for a specific branch. Used by
// compare_branches (which has to read both branches in one shot to
// compute the delta). The returned slice never aliases the manager's
// internal state. Returns ErrBranchNotFound when the branch is
// unknown.
func (m *OverlayManager) FilesForBranch(sessionID, branchName string) ([]OverlayFile, error) {
	if m == nil {
		return nil, ErrSessionNotFound
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	sess, ok := m.sessions[sessionID]
	if !ok {
		return nil, ErrSessionNotFound
	}
	br, ok := sess.branches[branchName]
	if !ok {
		return nil, ErrBranchNotFound
	}
	sess.LastUsed = time.Now()
	out := make([]OverlayFile, 0, len(br.files))
	for _, f := range br.files {
		out = append(out, f)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
	return out, nil
}
