package memorysearch

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"path/filepath"
	"strconv"
	"strings"
)

// BindingSchemaVersion is the current binding identity schema. Bumping it
// invalidates every prior binding fingerprint and forces a clean rebuild on
// the next derivative build.
const BindingSchemaVersion = 1

// memoryCloneDir is the final path segment of a managed Memory clone under an
// agent work directory. ResolvedRoot is always `<agentWorkDir>/memory`.
const memoryCloneDir = "memory"

// AgentWorkDirResolver returns the resolved agent work directory for an
// agent ID. The Desktop daemon injects profile.AgentWorkDir; tests inject
// synthetic resolvers. Returning an empty string signals that the agent has
// no resolved work directory and the binding cannot be constructed.
type AgentWorkDirResolver func(agentID string) string

// Binding captures the stable identity of an indexed managed Memory clone.
// ResolvedRoot is derived exclusively from the injected resolver at
// construction time and validated against the same resolver; runtime-supplied
// paths or session keys must never populate any Binding field.
//
// Callers must construct Binding through NewBinding. Direct struct literal
// construction is reserved for deserialization and must re-run ValidateWith
// against the production resolver before the derivative may be built or
// queried.
type Binding struct {
	SchemaVersion   int
	LocalAgentID    string
	MemoryID        string
	Branch          string
	ResolvedRoot    string
	AnalyzerVersion string
	ChunkerVersion  string
}

// NewBinding constructs a validated Binding. ResolvedRoot is derived
// exclusively from resolver(agentID) joined with the memory clone segment;
// the caller cannot supply or override the resolved root. The Desktop daemon
// passes profile.AgentWorkDir as the resolver.
func NewBinding(resolver AgentWorkDirResolver, agentID, memoryID, branch, analyzerVersion, chunkerVersion string) (*Binding, error) {
	if resolver == nil {
		return nil, fmt.Errorf("memory search: binding requires an agent work dir resolver: %w", ErrInvalidBinding)
	}
	workDir := strings.TrimSpace(resolver(agentID))
	if workDir == "" {
		return nil, fmt.Errorf("memory search: agent %q has no resolved work directory: %w", agentID, ErrInvalidBinding)
	}
	if !filepath.IsAbs(filepath.Clean(workDir)) {
		return nil, fmt.Errorf("memory search: agent work dir %q is not absolute: %w", workDir, ErrInvalidBinding)
	}
	b := &Binding{
		SchemaVersion:   BindingSchemaVersion,
		LocalAgentID:    strings.TrimSpace(agentID),
		MemoryID:        strings.TrimSpace(memoryID),
		Branch:          strings.TrimSpace(branch),
		ResolvedRoot:    filepath.Join(workDir, memoryCloneDir),
		AnalyzerVersion: strings.TrimSpace(analyzerVersion),
		ChunkerVersion:  strings.TrimSpace(chunkerVersion),
	}
	if err := b.validateIdentity(); err != nil {
		return nil, err
	}
	return b, nil
}

// ValidateWith re-runs the identity and containment contract checks against
// the same resolver that produced the binding. The daemon calls this after
// deserializing a Binding from disk to confirm the on-disk ResolvedRoot
// still matches what the resolver would produce. A mismatch means the
// resolved root was tampered with on disk or the agent workspace moved.
func (b *Binding) ValidateWith(resolver AgentWorkDirResolver) error {
	if err := b.validateIdentity(); err != nil {
		return err
	}
	if resolver == nil {
		return fmt.Errorf("memory search: ValidateWith requires a resolver: %w", ErrInvalidBinding)
	}
	want := filepath.Join(strings.TrimSpace(resolver(b.LocalAgentID)), memoryCloneDir)
	if want != filepath.Clean(b.ResolvedRoot) {
		return fmt.Errorf("memory search: resolved root %q does not match resolver-derived %q: %w",
			b.ResolvedRoot, want, ErrInvalidBinding)
	}
	return nil
}

// validateIdentity enforces the binding's identity contract: schema version,
// non-empty fields, and absolute path with the memory clone tail. It does
// not check containment against a resolver; use ValidateWith for that.
func (b *Binding) validateIdentity() error {
	if b == nil {
		return fmt.Errorf("memory search: binding is nil: %w", ErrInvalidBinding)
	}
	if b.SchemaVersion != BindingSchemaVersion {
		return fmt.Errorf("memory search: binding schema %d does not match current %d: %w",
			b.SchemaVersion, BindingSchemaVersion, ErrInvalidBinding)
	}
	if strings.TrimSpace(b.LocalAgentID) == "" {
		return fmt.Errorf("memory search: binding local agent id is empty: %w", ErrInvalidBinding)
	}
	if strings.TrimSpace(b.MemoryID) == "" {
		return fmt.Errorf("memory search: binding memory id is empty: %w", ErrInvalidBinding)
	}
	if strings.TrimSpace(b.Branch) == "" {
		return fmt.Errorf("memory search: binding branch is empty: %w", ErrInvalidBinding)
	}
	if strings.TrimSpace(b.AnalyzerVersion) == "" || strings.TrimSpace(b.ChunkerVersion) == "" {
		return fmt.Errorf("memory search: binding analyzer/chunker version is empty: %w", ErrInvalidBinding)
	}
	root := filepath.Clean(b.ResolvedRoot)
	if !filepath.IsAbs(root) {
		return fmt.Errorf("memory search: resolved root %q is not absolute: %w", b.ResolvedRoot, ErrInvalidBinding)
	}
	if filepath.Base(root) != memoryCloneDir {
		return fmt.Errorf("memory search: resolved root %q must end in %q: %w",
			b.ResolvedRoot, memoryCloneDir, ErrInvalidBinding)
	}
	return nil
}

// RetentionMetadata mirrors vault.RetentionState for the clone-side retention
// marker that must agree with the binding before the derivative may be built
// or queried. The fields intentionally match the on-disk JSON shape.
type RetentionMetadata struct {
	SchemaVersion int    `json:"schema_version"`
	MemoryID      string `json:"memory_id"`
	AgentID       string `json:"agent_id,omitempty"`
	Branch        string `json:"branch"`
	RevokedAt     string `json:"revoked_at,omitempty"`
}

// Fingerprint returns the deterministic binding fingerprint. The fingerprint
// changes on rebind, branch switch, schema/analyzer/chunker version bump, or
// resolved root change. It never embeds auth tokens, origin credentials, or
// user names.
func (b Binding) Fingerprint() string {
	fields := []string{
		strconv.Itoa(b.SchemaVersion),
		b.LocalAgentID,
		b.MemoryID,
		strings.TrimSpace(b.Branch),
		filepath.Clean(b.ResolvedRoot),
		b.AnalyzerVersion,
		b.ChunkerVersion,
	}
	h := sha256.New()
	for _, f := range fields {
		h.Write([]byte(f))
		h.Write([]byte{0})
	}
	return hex.EncodeToString(h.Sum(nil))
}

// MatchesRetention reports whether clone-side retention metadata agrees with
// the binding. A mismatch means the on-disk clone is not the clone the
// derivative was built against, and the derivative must not be served.
//
// Fail-closed: a revoked clone (RevokedAt != "") or schema drift always
// rejects. AgentID is checked only when the metadata carries one, matching
// vault.RetentionState's optional agent binding semantics.
func (b Binding) MatchesRetention(meta RetentionMetadata) error {
	if meta.SchemaVersion != BindingSchemaVersion {
		return fmt.Errorf("memory search: retention schema %d does not match current %d: %w",
			meta.SchemaVersion, BindingSchemaVersion, ErrRetentionMismatch)
	}
	if meta.MemoryID != b.MemoryID {
		return fmt.Errorf("memory search: retention memory id %q does not match binding %q: %w",
			meta.MemoryID, b.MemoryID, ErrRetentionMismatch)
	}
	if strings.TrimSpace(meta.Branch) != strings.TrimSpace(b.Branch) {
		return fmt.Errorf("memory search: retention branch %q does not match binding %q: %w",
			meta.Branch, b.Branch, ErrRetentionMismatch)
	}
	if strings.TrimSpace(meta.AgentID) != "" && strings.TrimSpace(meta.AgentID) != strings.TrimSpace(b.LocalAgentID) {
		return fmt.Errorf("memory search: retention agent id %q does not match binding %q: %w",
			meta.AgentID, b.LocalAgentID, ErrRetentionMismatch)
	}
	if strings.TrimSpace(meta.RevokedAt) != "" {
		return fmt.Errorf("memory search: retention marker is revoked at %s: %w",
			meta.RevokedAt, ErrRetentionMismatch)
	}
	return nil
}

var (
	// ErrRetentionMismatch signals that the clone-side retention marker
	// disagrees with the binding identity. Callers detect schema drift,
	// memory-id / branch / agent mismatch, or revoked clones through errors.Is.
	ErrRetentionMismatch = errors.New("memory search: retention metadata does not match binding")
	// ErrInvalidBinding signals that a Binding failed identity or containment
	// validation. Callers must not construct, persist, or serve derivatives
	// for an invalid binding.
	ErrInvalidBinding = errors.New("memory search: invalid binding")
)
