package memorysearch

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"time"
)

// ManifestSchemaVersion is the on-disk manifest format version. Bumping it
// invalidates every prior manifest; the build path treats an unknown schema
// as a degraded state and refuses to publish a generation whose manifest it
// cannot interpret.
const ManifestSchemaVersion = 2

// manifestFilename is the fixed name of the manifest file inside a
// generation directory. The name is stable across versions so that a future
// reader can always locate the manifest even if other layout details change.
const manifestFilename = "manifest.json"

// Manifest is the durable sidecar of a generation. It carries exactly the
// state the query path needs to:
//
//   - Reject a generation whose binding identity has drifted
//     (BindingFingerprint, Binding).
//   - Detect when the source clone has moved under the derivative
//     (Binding.ResolvedRoot validated via Binding.ValidateWith on open).
//   - Re-verify a source file before serving a hit
//     (Files[path].ContentHash vs the current clone content hash).
//   - Drop a chunk whose file was removed (Files[path].ChunkIDs owns the
//     chunk inventory; chunk entries below are a flat index for O(1) lookup).
//
// Manifest intentionally does NOT record Bleve-internal state, segment
// counts, or anything that would tempt a caller to treat the manifest as a
// health API. Health is exposed through the dedicated Status struct.
type Manifest struct {
	SchemaVersion      int                   `json:"schema_version"`
	AnalyzerVersion    string                `json:"analyzer_version"`
	ChunkerVersion     string                `json:"chunker_version"`
	BindingFingerprint string                `json:"binding_fingerprint"`
	Binding            Binding               `json:"binding"`
	SourceRevision     string                `json:"source_revision"`
	BuildStartedAt     time.Time             `json:"build_started_at"`
	BuildCompletedAt   time.Time             `json:"build_completed_at"`
	Files              map[string]FileEntry  `json:"files"`
	Chunks             map[string]ChunkEntry `json:"chunks"`

	// checksum is set by SaveAtomic and read by Checksum; it is never
	// serialized because json.Marshal skips unexported fields. It lets tests
	// and the build pipeline detect tampering after SaveAtomic without
	// re-hashing the file.
	checksum string `json:"-"`
}

// FileEntry mirrors the stable attributes of one SourceFile at scan time.
// ContentHash is the response-contract hash the query path re-checks before
// returning a hit; ChunkIDs lets the reconciler enumerate the chunks to
// remove when the file changes or disappears.
type FileEntry struct {
	Path        string   `json:"path"`
	Size        int64    `json:"size"`
	MTimeNS     int64    `json:"mtime_ns"`
	ContentHash string   `json:"content_hash"`
	Kind        string   `json:"kind"`
	Format      string   `json:"format"`
	Archived    bool     `json:"archived"`
	ChunkIDs    []string `json:"chunk_ids,omitempty"`
}

// ChunkEntry is the flat chunk inventory the query path uses to verify that
// a chunk ID still belongs to a file the manifest trusts. Path points at the
// source-relative file; ContentHash mirrors SearchChunk.ContentHash so a
// hit can be dropped without consulting Files first when the file-level hash
// already matches.
type ChunkEntry struct {
	ID          string `json:"id"`
	Path        string `json:"path"`
	ContentHash string `json:"content_hash"`
}

// ErrManifestMissing signals that no manifest file was found at the supplied
// path. Callers treat this as "no claim of readiness" per the spec.
var ErrManifestMissing = errors.New("memory search: manifest missing")

// ErrManifestSchemaUnknown signals that the manifest on disk carries a
// schema version the current build does not understand. The derivative must
// be rebuilt.
var ErrManifestSchemaUnknown = errors.New("memory search: manifest schema unknown")

// ErrManifestBindingMismatch signals that the manifest's binding fingerprint
// does not match the binding the caller is trying to serve. The derivative
// is for a different binding.
var ErrManifestBindingMismatch = errors.New("memory search: manifest binding mismatch")

// LoadManifest reads and validates the manifest at path. Validation checks
// the on-disk schema version and re-runs Binding.ValidateWith via the
// caller-supplied resolver. A mismatch on either is fail-closed: the caller
// must treat the generation as not ready.
//
// The resolver is required even when the caller only wants to read fields
// from the manifest: opening a manifest is itself a trust act, because the
// query path will use the manifest's ContentHash values to admit hits.
func LoadManifest(ctx context.Context, path string, resolver AgentWorkDirResolver) (*Manifest, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil, fmt.Errorf("%w: %v", ErrManifestMissing, err)
		}
		return nil, err
	}
	m := &Manifest{}
	dec := json.NewDecoder(bytes.NewReader(data))
	dec.DisallowUnknownFields()
	if err := dec.Decode(m); err != nil {
		return nil, fmt.Errorf("memory search: manifest decode failed: %w", err)
	}
	if err := m.Validate(resolver); err != nil {
		return nil, err
	}
	return m, nil
}

// Validate re-checks the manifest's on-disk identity. It is called by
// LoadManifest and may also be called by callers that constructed a Manifest
// in memory before publishing it. The resolver is required because source
// root containment is the trust boundary.
func (m *Manifest) Validate(resolver AgentWorkDirResolver) error {
	if m == nil {
		return fmt.Errorf("%w: nil manifest", ErrManifestMissing)
	}
	if m.SchemaVersion != ManifestSchemaVersion {
		return fmt.Errorf("%w: manifest schema %d does not match current %d",
			ErrManifestSchemaUnknown, m.SchemaVersion, ManifestSchemaVersion)
	}
	if err := m.Binding.ValidateWith(resolver); err != nil {
		return err
	}
	if got := m.Binding.Fingerprint(); got != m.BindingFingerprint {
		return fmt.Errorf("%w: manifest fingerprint %q does not match binding %q",
			ErrManifestBindingMismatch, m.BindingFingerprint, got)
	}
	if m.AnalyzerVersion == "" || m.ChunkerVersion == "" {
		return fmt.Errorf("memory search: manifest missing analyzer or chunker version")
	}
	if m.SourceRevision == "" {
		return fmt.Errorf("memory search: manifest missing source revision")
	}
	if len(m.Files) == 0 && len(m.Chunks) > 0 {
		return fmt.Errorf("memory search: manifest has chunks without files")
	}
	// Forward pass: every ChunkID claimed by a FileEntry must resolve to a
	// ChunkEntry that points back at the same file.
	for path, entry := range m.Files {
		if path != entry.Path {
			return fmt.Errorf("memory search: manifest file map key %q does not match entry path %q",
				path, entry.Path)
		}
		if entry.ContentHash == "" {
			return fmt.Errorf("memory search: manifest file %q missing content hash", path)
		}
		for _, id := range entry.ChunkIDs {
			ce, ok := m.Chunks[id]
			if !ok {
				return fmt.Errorf("memory search: manifest file %q references unknown chunk %q",
					path, id)
			}
			if ce.Path != path {
				return fmt.Errorf("memory search: manifest chunk %q points at %q but listed under %q",
					id, ce.Path, path)
			}
			// A chunk's content hash must equal its owning file's content
			// hash. The query path uses Files[path].ContentHash to admit a
			// hit; if a tampered manifest listed a different hash on the
			// chunk entry, the response-contract re-check would still pass
			// while serving bytes the chunk entry never vouched for.
			if ce.ContentHash != entry.ContentHash {
				return fmt.Errorf("memory search: manifest chunk %q hash %q does not match file %q hash %q",
					id, ce.ContentHash, path, entry.ContentHash)
			}
		}
	}
	// Reverse pass: every ChunkEntry must be owned by exactly one file, its
	// map key must equal its ID, and its content hash must match its file.
	owner := make(map[string]string, len(m.Chunks)) // chunkID -> owning path
	for path, entry := range m.Files {
		for _, id := range entry.ChunkIDs {
			if prev, dup := owner[id]; dup {
				return fmt.Errorf("memory search: manifest chunk %q is owned by both %q and %q",
					id, prev, path)
			}
			owner[id] = path
		}
	}
	for id, ce := range m.Chunks {
		if id != ce.ID {
			return fmt.Errorf("memory search: manifest chunk map key %q does not match entry ID %q",
				id, ce.ID)
		}
		if _, owned := owner[id]; !owned {
			return fmt.Errorf("memory search: manifest chunk %q is not owned by any file",
				id)
		}
	}
	return nil
}

// SaveAtomic writes the manifest to a sibling temp file and renames it into
// place. Rename is atomic on the platforms AgentTeam supports; a partial write is
// never observable as a valid manifest. The caller must have already run
// Validate; SaveAtomic does not re-validate.
func (m *Manifest) SaveAtomic(path string) error {
	if m == nil {
		return fmt.Errorf("%w: nil manifest", ErrManifestMissing)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	payload, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	sum := sha256.Sum256(payload)
	tmp, err := os.CreateTemp(filepath.Dir(path), ".manifest-*.tmp")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	cleanup := func() {
		_ = tmp.Close()
		_ = os.Remove(tmpName)
	}
	if _, err := tmp.Write(payload); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Sync(); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Close(); err != nil {
		_ = os.Remove(tmpName)
		return err
	}
	if err := os.Rename(tmpName, path); err != nil {
		_ = os.Remove(tmpName)
		return err
	}
	m.checksum = hex.EncodeToString(sum[:])
	return nil
}

// Checksum returns the hex sha256 of the most recently marshalled payload, or
// empty if the manifest has never been saved. The checksum is unexported so
// json.Marshal skips it; a manifest cannot vouch for its own bytes because
// the checksum would have to live inside the bytes it describes.
func (m *Manifest) Checksum() string { return m.checksum }

// FindChunk returns the chunk entry for id, or (zero, false) when the chunk
// is not in this manifest. Used by the query path to drop hits whose chunk
// disappeared between build and query.
func (m *Manifest) FindChunk(id string) (ChunkEntry, bool) {
	ce, ok := m.Chunks[id]
	return ce, ok
}

// FileForPath returns the file entry for the source-relative path, or
// (zero, false) when the file is not in this manifest.
func (m *Manifest) FileForPath(path string) (FileEntry, bool) {
	fe, ok := m.Files[path]
	return fe, ok
}

// SortedPaths returns the manifest's file paths in deterministic order. The
// reconciler uses this to diff against a fresh SourceSnapshot.
func (m *Manifest) SortedPaths() []string {
	out := make([]string, 0, len(m.Files))
	for p := range m.Files {
		out = append(out, p)
	}
	sort.Strings(out)
	return out
}

// newManifestFromSnapshot constructs an in-memory Manifest from a binding, a
// SourceSnapshot, and the chunks derived from that snapshot. It is fail-
// closed: every chunk must reference a real snapshot path, every chunk's
// ContentHash / Kind / Format / Archived / SourceRevision must match the
// snapshot, and chunk IDs must be non-empty and unique. A violation means
// the caller's chunker pipeline is inconsistent with the snapshot it claims
// to derive from; returning a manifest anyway would publish a generation
// whose response-contract hash checks silently disagree with the source
// files they claim to describe.
func newManifestFromSnapshot(b *Binding, snap SourceSnapshot, chunks []SearchChunk, startedAt, completedAt time.Time) (*Manifest, error) {
	if b == nil {
		return nil, fmt.Errorf("%w: nil binding", ErrInvalidChunks)
	}
	if snap.Revision == "" {
		return nil, fmt.Errorf("%w: snapshot missing revision", ErrInvalidChunks)
	}
	seen := map[string]struct{}{}
	for i, c := range chunks {
		if c.ID == "" {
			return nil, fmt.Errorf("%w: chunk[%d] has empty ID", ErrInvalidChunks, i)
		}
		if _, dup := seen[c.ID]; dup {
			return nil, fmt.Errorf("%w: chunk ID %q appears more than once", ErrInvalidChunks, c.ID)
		}
		seen[c.ID] = struct{}{}
		sf, ok := snap.Files[c.Path]
		if !ok {
			return nil, fmt.Errorf("%w: chunk %q references path %q not in snapshot",
				ErrInvalidChunks, c.ID, c.Path)
		}
		if c.ContentHash != sf.ContentHash {
			return nil, fmt.Errorf("%w: chunk %q content hash %q does not match snapshot hash %q for %q",
				ErrInvalidChunks, c.ID, c.ContentHash, sf.ContentHash, c.Path)
		}
		if c.SourceRevision != snap.Revision {
			return nil, fmt.Errorf("%w: chunk %q source revision %q does not match snapshot revision %q",
				ErrInvalidChunks, c.ID, c.SourceRevision, snap.Revision)
		}
		if c.Kind != sf.Kind {
			return nil, fmt.Errorf("%w: chunk %q kind %q does not match file kind %q",
				ErrInvalidChunks, c.ID, c.Kind, sf.Kind)
		}
		if c.Format != sf.Format {
			return nil, fmt.Errorf("%w: chunk %q format %q does not match file format %q",
				ErrInvalidChunks, c.ID, c.Format, sf.Format)
		}
		if c.Archived != sf.Archived {
			return nil, fmt.Errorf("%w: chunk %q archived=%v does not match file archived=%v",
				ErrInvalidChunks, c.ID, c.Archived, sf.Archived)
		}
	}

	files := make(map[string]FileEntry, len(snap.Files))
	for path, sf := range snap.Files {
		files[path] = FileEntry{
			Path:        sf.Path,
			Size:        sf.Size,
			MTimeNS:     sf.MTimeNS,
			ContentHash: sf.ContentHash,
			Kind:        sf.Kind,
			Format:      sf.Format,
			Archived:    sf.Archived,
		}
	}
	entries := make(map[string]ChunkEntry, len(chunks))
	for _, c := range chunks {
		entries[c.ID] = ChunkEntry{
			ID:          c.ID,
			Path:        c.Path,
			ContentHash: c.ContentHash,
		}
		fe := files[c.Path]
		fe.ChunkIDs = append(fe.ChunkIDs, c.ID)
		files[c.Path] = fe
	}
	return &Manifest{
		SchemaVersion:      ManifestSchemaVersion,
		AnalyzerVersion:    b.AnalyzerVersion,
		ChunkerVersion:     b.ChunkerVersion,
		BindingFingerprint: b.Fingerprint(),
		Binding:            *b,
		SourceRevision:     snap.Revision,
		BuildStartedAt:     startedAt,
		BuildCompletedAt:   completedAt,
		Files:              files,
		Chunks:             entries,
	}, nil
}

// ErrInvalidChunks signals that the chunk inventory supplied to Build does
// not agree with the snapshot it claims to be derived from. The build
// refuses to publish a generation whose manifest would disagree with the
// source files it references.
var ErrInvalidChunks = errors.New("memory search: chunks inconsistent with snapshot")
