package index

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"io"
	"os"
	"sort"
	"strings"

	pchunk "github.com/dusk-network/pituitary/internal/chunk"
	"github.com/dusk-network/pituitary/internal/config"
	"github.com/dusk-network/pituitary/internal/source"
	stindex "github.com/dusk-network/stroma/v3/index"
	ststore "github.com/dusk-network/stroma/v3/store"
)

// UpdatePreconditionError reports that the existing index is structurally
// incompatible with the current configuration and requires a full rebuild.
type UpdatePreconditionError struct {
	Reason string
	Action string
}

func (e *UpdatePreconditionError) Error() string {
	if strings.TrimSpace(e.Action) != "" {
		return fmt.Sprintf("%s; %s", e.Reason, e.Action)
	}
	return e.Reason
}

// IsUpdatePrecondition reports whether err wraps an update-precondition failure.
func IsUpdatePrecondition(err error) bool {
	var target *UpdatePreconditionError
	return errors.As(err, &target)
}

// UpdateOptions controls optional update behavior.
type UpdateOptions struct {
	ComputeDelta bool
}

// UpdateContextWithOptions performs an index update, using Stroma incremental
// snapshot updates when the existing index is structurally compatible and
// falling back to a full rebuild otherwise.
func UpdateContextWithOptions(ctx context.Context, cfg *config.Config, records *source.LoadResult) (*RebuildResult, error) {
	return updateContext(ctx, cfg, records, UpdateOptions{}, nil)
}

// UpdateWithProgressContextAndOptions performs an update with progress
// reporting.
func UpdateWithProgressContextAndOptions(ctx context.Context, cfg *config.Config, records *source.LoadResult, reporter RebuildProgressReporter) (*RebuildResult, error) {
	return updateContext(ctx, cfg, records, UpdateOptions{}, reporter)
}

// UpdateWithDeltaContextAndOptions performs an update with progress reporting
// and governance delta computation.
func UpdateWithDeltaContextAndOptions(ctx context.Context, cfg *config.Config, records *source.LoadResult, options UpdateOptions, reporter RebuildProgressReporter) (*RebuildResult, error) {
	return updateContext(ctx, cfg, records, options, reporter)
}

func updateContext(ctx context.Context, cfg *config.Config, records *source.LoadResult, options UpdateOptions, reporter RebuildProgressReporter) (*RebuildResult, error) {
	if cfg == nil {
		return nil, fmt.Errorf("config is required")
	}
	if records == nil {
		return nil, fmt.Errorf("records are required")
	}
	indexPath := cfg.Workspace.ResolvedIndexPath

	info, err := os.Stat(indexPath)
	switch {
	case os.IsNotExist(err):
		return nil, &MissingIndexError{Path: indexPath}
	case err != nil:
		return nil, fmt.Errorf("stat index %s: %w", indexPath, err)
	case info.IsDir():
		return nil, fmt.Errorf("index path %s is a directory", indexPath)
	}

	db, err := OpenReadOnlyContext(ctx, indexPath)
	if err != nil {
		return nil, fmt.Errorf("open index %s: %w", indexPath, err)
	}
	closeDB := true
	defer func() {
		if closeDB && db != nil {
			_ = db.Close()
		}
	}()

	storedRefs, err := loadStoredArtifactRefsContext(ctx, db)
	if err != nil {
		return nil, err
	}
	diff := computeArtifactDiff(storedRefs, records)

	embedder, err := prepareRebuildContext(ctx, cfg, records)
	if err != nil {
		return nil, err
	}
	dimension, err := embedder.Dimension(ctx)
	if err != nil {
		return nil, err
	}
	currentSnapshotPath, err := validateIncrementalUpdateEligibilityContext(ctx, db, cfg, embedder, dimension)
	if err != nil && !IsUpdatePrecondition(err) {
		return nil, err
	}
	shouldFallback := IsUpdatePrecondition(err)

	var oldEdges []snapshotEdge
	var oldArtifacts []snapshotArtifact
	if options.ComputeDelta {
		oldEdges, err = snapshotEdgesContext(ctx, db)
		if err != nil {
			return nil, fmt.Errorf("snapshot old edges: %w", err)
		}
		oldArtifacts, err = snapshotSpecArtifactsContext(ctx, db)
		if err != nil {
			return nil, fmt.Errorf("snapshot old artifacts: %w", err)
		}
	}
	if err := db.Close(); err != nil {
		return nil, fmt.Errorf("close index %s before rebuild: %w", indexPath, err)
	}
	closeDB = false
	db = nil

	if shouldFallback {
		return rebuildUpdateContext(ctx, cfg, records, diff, options, reporter, oldArtifacts, oldEdges)
	}

	reuseState, err := loadReuseStateContext(ctx, currentSnapshotPath, embedder.Fingerprint(), dimension, RebuildOptions{})
	if err != nil {
		return nil, err
	}
	result, err := summarizeRebuild(records, dimension, reuseState, RebuildOptions{})
	if err != nil {
		return nil, err
	}
	result.IndexPath = indexPath
	result.Update = true
	result.FullRebuild = false
	result.AddedCount = len(diff.added)
	result.UpdatedCount = len(diff.updated)
	result.RemovedCount = len(diff.removed)
	result.UnchangedCount = len(diff.unchanged)

	snapshotPath, cleanupSnapshot, err := updateStromaSnapshotContext(ctx, cfg, records, diff, currentSnapshotPath, embedder, reuseState, reporter)
	if err != nil {
		if IsUpdatePrecondition(err) {
			return rebuildUpdateContext(ctx, cfg, records, diff, options, reporter, oldArtifacts, oldEdges)
		}
		return nil, err
	}
	snapshotPublished := false
	defer func() {
		if !snapshotPublished && cleanupSnapshot != nil {
			cleanupSnapshot()
		}
	}()

	result.ContentFingerprint = contentFingerprint(records)
	// Same reason as rebuildContext: summarizeRebuild's predictive
	// Markdown-based ChunkCount undercounts doc chunks once
	// LateChunkPolicy emits parent+leaf hierarchies, so refresh from
	// the written snapshot before publishing.
	if err := refreshRebuildChunkCountFromSnapshotContext(ctx, snapshotPath, result); err != nil {
		return nil, err
	}
	if err := publishBusinessIndexContext(ctx, cfg, records, result, snapshotPath); err != nil {
		return nil, err
	}
	snapshotPublished = true
	if err := loadUpdateDeltaContext(ctx, indexPath, result, options, oldArtifacts, oldEdges); err != nil {
		return nil, err
	}

	return result, nil
}

// storedArtifactRef holds the minimal stored artifact data needed for diffing.
type storedArtifactRef struct {
	ref         string
	contentHash string
}

func loadStoredArtifactRefsContext(ctx context.Context, db *sql.DB) ([]storedArtifactRef, error) {
	rows, err := db.QueryContext(ctx, `SELECT ref, content_hash FROM artifacts`)
	if err != nil {
		return nil, fmt.Errorf("query stored artifact refs: %w", err)
	}
	defer rows.Close()

	var refs []storedArtifactRef
	for rows.Next() {
		var r storedArtifactRef
		if err := rows.Scan(&r.ref, &r.contentHash); err != nil {
			return nil, fmt.Errorf("scan stored artifact ref: %w", err)
		}
		refs = append(refs, r)
	}
	return refs, rows.Err()
}

// artifactDiff classifies artifacts into add/update/remove/unchanged buckets.
type artifactDiff struct {
	added     []string // refs present in records but not in DB
	updated   []string // refs present in both but content_hash differs
	removed   []string // refs present in DB but not in records
	unchanged []string // refs present in both with matching content_hash
}

func computeArtifactDiff(stored []storedArtifactRef, records *source.LoadResult) artifactDiff {
	storedMap := make(map[string]string, len(stored))
	for _, s := range stored {
		storedMap[s.ref] = s.contentHash
	}

	desiredMap := make(map[string]string, len(records.Specs)+len(records.Docs))
	for _, spec := range records.Specs {
		desiredMap[spec.Ref] = spec.ContentHash
	}
	for _, doc := range records.Docs {
		desiredMap[doc.Ref] = doc.ContentHash
	}

	var diff artifactDiff

	// Classify desired refs against stored.
	for ref, desiredHash := range desiredMap {
		storedHash, exists := storedMap[ref]
		switch {
		case !exists:
			diff.added = append(diff.added, ref)
		case storedHash != desiredHash:
			diff.updated = append(diff.updated, ref)
		default:
			diff.unchanged = append(diff.unchanged, ref)
		}
	}

	// Find removed refs.
	for ref := range storedMap {
		if _, exists := desiredMap[ref]; !exists {
			diff.removed = append(diff.removed, ref)
		}
	}

	sort.Strings(diff.added)
	sort.Strings(diff.updated)
	sort.Strings(diff.removed)
	sort.Strings(diff.unchanged)

	return diff
}

func rebuildUpdateContext(
	ctx context.Context,
	cfg *config.Config,
	records *source.LoadResult,
	diff artifactDiff,
	options UpdateOptions,
	reporter RebuildProgressReporter,
	oldArtifacts []snapshotArtifact,
	oldEdges []snapshotEdge,
) (*RebuildResult, error) {
	result, err := rebuildContext(ctx, cfg, records, RebuildOptions{}, reporter)
	if err != nil {
		return nil, err
	}
	result.Update = true
	result.FullRebuild = true
	result.AddedCount = len(diff.added)
	result.UpdatedCount = len(diff.updated)
	result.RemovedCount = len(diff.removed)
	result.UnchangedCount = len(diff.unchanged)
	if err := loadUpdateDeltaContext(ctx, cfg.Workspace.ResolvedIndexPath, result, options, oldArtifacts, oldEdges); err != nil {
		return nil, err
	}
	return result, nil
}

func loadUpdateDeltaContext(ctx context.Context, indexPath string, result *RebuildResult, options UpdateOptions, oldArtifacts []snapshotArtifact, oldEdges []snapshotEdge) error {
	if !options.ComputeDelta {
		return nil
	}

	newDB, err := OpenReadOnlyContext(ctx, indexPath)
	if err != nil {
		return fmt.Errorf("reopen updated index %s: %w", indexPath, err)
	}
	defer newDB.Close()

	newEdges, err := snapshotEdgesContext(ctx, newDB)
	if err != nil {
		return fmt.Errorf("snapshot new edges: %w", err)
	}
	newArtifacts, err := snapshotSpecArtifactsContext(ctx, newDB)
	if err != nil {
		return fmt.Errorf("snapshot new artifacts: %w", err)
	}
	result.Delta = computeGovernanceDelta(oldArtifacts, newArtifacts, oldEdges, newEdges)
	return nil
}

func validateIncrementalUpdateEligibilityContext(ctx context.Context, db *sql.DB, cfg *config.Config, embedder Embedder, dimension int) (string, error) {
	storedSchemaVersion, err := readRequiredMetadataValueContext(ctx, db, "schema_version")
	if err != nil {
		return "", &UpdatePreconditionError{Reason: err.Error()}
	}
	if strings.TrimSpace(storedSchemaVersion) != fmt.Sprintf("%d", schemaVersion) {
		return "", &UpdatePreconditionError{
			Reason: fmt.Sprintf("index schema_version %q does not match expected schema_version %d", strings.TrimSpace(storedSchemaVersion), schemaVersion),
			Action: "run `pituitary index --rebuild`",
		}
	}
	if err := validateStoredEmbedderContext(ctx, db, embedder.Fingerprint(), dimension); err != nil {
		return "", &UpdatePreconditionError{Reason: err.Error()}
	}
	if err := validateStoredChunkingConfigContext(ctx, db, cfg.Runtime.Chunking); err != nil {
		return "", &UpdatePreconditionError{Reason: err.Error(), Action: "run `pituitary index --rebuild`"}
	}

	snapshotPath, err := stromaSnapshotPathFromDBContext(ctx, db, cfg.Workspace.ResolvedIndexPath)
	if err != nil {
		return "", &UpdatePreconditionError{Reason: err.Error()}
	}
	info, err := os.Stat(snapshotPath)
	switch {
	case os.IsNotExist(err):
		return "", &UpdatePreconditionError{
			Reason: fmt.Sprintf("stroma snapshot %s does not exist", snapshotPath),
			Action: "run `pituitary index --rebuild`",
		}
	case err != nil:
		return "", fmt.Errorf("stat stroma snapshot %s: %w", snapshotPath, err)
	case info.IsDir():
		return "", &UpdatePreconditionError{
			Reason: fmt.Sprintf("stroma snapshot path %s is a directory", snapshotPath),
			Action: "run `pituitary index --rebuild`",
		}
	}
	if err := validateStoredSnapshotVectorFormatContext(ctx, snapshotPath); err != nil {
		return "", &UpdatePreconditionError{Reason: err.Error(), Action: "run `pituitary index --rebuild`"}
	}
	return snapshotPath, nil
}

func validateStoredSnapshotVectorFormatContext(ctx context.Context, snapshotPath string) error {
	storedFormat, err := readStoredSnapshotVectorFormatContext(ctx, snapshotPath)
	if err != nil {
		return err
	}
	if storedFormat != defaultStromaVectorFormat {
		return fmt.Errorf("stroma snapshot vector storage format %q is unsupported by this Pituitary version", storedFormat)
	}
	return nil
}

// validateStoredChunkingConfigContext rejects an incremental update
// whose current chunking/contextualizer config would diverge from the
// config in force when the snapshot was built. Without this gate,
// updating after a config change would re-chunk changed records under
// the new config while leaving unchanged records on the old chunk
// shapes — a silent mixed-generation snapshot.
//
// Older snapshots (built before this fingerprint was persisted) have
// no stored value. That was safe while resolver defaults were frozen,
// because rebuild.go honored the active config even without recording
// the fingerprint. Once pchunk.ResolverDefaultsVersion advances past
// "1" (i.e., the resolver's zero-config output has changed since the
// pre-fingerprint era), that premise breaks: an `--update` against a
// pre-fingerprint snapshot would re-chunk only the changed records
// under the new default and leave the rest on the old layout — the
// exact mixed-generation hazard this gate exists to prevent. So we
// reject missing fingerprints whenever the resolver has moved past v1
// and route the caller through a full rebuild instead.
func validateStoredChunkingConfigContext(ctx context.Context, db *sql.DB, current config.ChunkingConfig) error {
	stored, err := readOptionalMetadataValueContext(ctx, db, "chunking_config_fingerprint")
	if err != nil {
		return err
	}
	stored = strings.TrimSpace(stored)
	if stored == "" {
		if pchunk.ResolverDefaultsVersion != "1" {
			return fmt.Errorf(
				"stored snapshot has no chunking_config_fingerprint and resolver defaults have advanced to version %q since it was built",
				pchunk.ResolverDefaultsVersion,
			)
		}
		return nil
	}
	want := chunkingConfigFingerprint(current)
	if stored != want {
		return fmt.Errorf("chunking config changed since last rebuild (stored %s, current %s)", stored, want)
	}
	return nil
}

func readRequiredMetadataValueContext(ctx context.Context, db *sql.DB, key string) (string, error) {
	value, err := readOptionalMetadataValueContext(ctx, db, key)
	if err != nil {
		return "", err
	}
	if strings.TrimSpace(value) == "" {
		return "", fmt.Errorf("index metadata is missing %s; run `pituitary index --rebuild`", key)
	}
	return value, nil
}

func updateStromaSnapshotContext(
	ctx context.Context,
	cfg *config.Config,
	records *source.LoadResult,
	diff artifactDiff,
	currentSnapshotPath string,
	embedder Embedder,
	reuseState *reuseState,
	reporter RebuildProgressReporter,
) (string, func(), error) {
	if len(diff.added) == 0 && len(diff.updated) == 0 && len(diff.removed) == 0 {
		return currentSnapshotPath, nil, nil
	}

	targetSnapshotPath := stromaSnapshotPathForContent(cfg.Workspace.ResolvedIndexPath, contentFingerprint(records))
	cleanupSnapshot, err := prepareSnapshotUpdateTarget(currentSnapshotPath, targetSnapshotPath)
	if err != nil {
		return "", nil, err
	}

	selected := selectedLoadResultForRefs(records, append(append([]string{}, diff.added...), diff.updated...))
	emitPlannedRebuildProgress(selected, reuseState, reporter)
	// Thread the same chunking config through stroma's Update path
	// that rebuild.go threads through stroma's Rebuild path. Without
	// this, a repo configured with per-kind chunking or an opt-in
	// contextualizer sees those settings honored on --rebuild but
	// silently ignored on --update, producing mixed chunk shapes or
	// mixed context_prefix semantics across the same snapshot.
	chunkPolicy, err := pchunk.Resolve(chunkConfigFromRuntime(cfg.Runtime.Chunking))
	if err != nil {
		if cleanupSnapshot != nil {
			cleanupSnapshot()
		}
		return "", nil, fmt.Errorf("resolve chunk policy: %w", err)
	}
	contextualizer, err := pchunk.ResolveContextualizer(chunkContextualizerFromRuntime(cfg.Runtime.Chunking))
	if err != nil {
		if cleanupSnapshot != nil {
			cleanupSnapshot()
		}
		return "", nil, fmt.Errorf("resolve chunk contextualizer: %w", err)
	}
	// SyncFromSource streams the full desired corpus into stroma. It
	// computes the (ref, content_hash) diff against the existing
	// snapshot itself — unchanged records are reused without
	// re-chunking or re-embedding their bodies (the bodies are still
	// resident here because the loader handed us a fully materialized
	// LoadResult; loader-side body reduction is the deferred follow-up
	// noted above), and stored records absent from the stream are
	// removed. Pituitary's per-source loader produces the union of all
	// configured sources, so SyncFromSource's auto-removal preserves
	// today's "configure source change → next update reflects
	// removals" contract without paying full-rebuild cost. Callers
	// that want partial-feed semantics (do not delete records absent
	// from the stream) should switch to UpdateFromSource explicitly.
	//
	// MaxPlannedRecords is intentionally not set here, so a single
	// update that touches a very large fraction of the corpus retains
	// the changed-record plan in memory until commit (today's behavior
	// before the streaming switch). For mass edits prefer
	// `pituitary index --rebuild`; an opt-in cap with a fall-back-to-
	// rebuild branch is tracked separately.
	if _, err := stindex.SyncFromSource(ctx, source.NewLoadResultRecordSource(records), stindex.UpdateOptions{
		Path:           targetSnapshotPath,
		Embedder:       embedder,
		ChunkPolicy:    chunkPolicy,
		Contextualizer: contextualizer,
	}); err != nil {
		if cleanupSnapshot != nil {
			cleanupSnapshot()
		}
		return "", nil, normalizeStromaUpdateError(targetSnapshotPath, err)
	}
	// Mirror the rebuild-path guard so an incremental update cannot
	// silently land a snapshot with doc leaves missing parent_chunk_id
	// under LateChunkPolicy. The validator inspects the published target.
	if docLateChunkActive(chunkPolicy) {
		if err := validateDocParentChainContext(ctx, targetSnapshotPath); err != nil {
			if cleanupSnapshot != nil {
				cleanupSnapshot()
			}
			return "", nil, err
		}
	}
	return targetSnapshotPath, cleanupSnapshot, nil
}

func selectedLoadResultForRefs(records *source.LoadResult, refs []string) *source.LoadResult {
	selected := &source.LoadResult{}
	if records == nil || len(refs) == 0 {
		return selected
	}

	selected.Sources = append(selected.Sources, records.Sources...)
	refSet := make(map[string]struct{}, len(refs))
	for _, ref := range refs {
		refSet[ref] = struct{}{}
	}
	for _, spec := range records.Specs {
		if _, ok := refSet[spec.Ref]; ok {
			selected.Specs = append(selected.Specs, spec)
		}
	}
	for _, doc := range records.Docs {
		if _, ok := refSet[doc.Ref]; ok {
			selected.Docs = append(selected.Docs, doc)
		}
	}
	return selected
}

func prepareSnapshotUpdateTarget(currentSnapshotPath, targetSnapshotPath string) (func(), error) {
	if strings.TrimSpace(currentSnapshotPath) == "" {
		return nil, &UpdatePreconditionError{
			Reason: "stroma snapshot metadata is missing",
			Action: "run `pituitary index --rebuild`",
		}
	}
	if currentSnapshotPath == targetSnapshotPath {
		return nil, nil
	}
	if err := os.Remove(targetSnapshotPath); err != nil && !os.IsNotExist(err) {
		return nil, fmt.Errorf("remove stale stroma snapshot %s: %w", targetSnapshotPath, err)
	}
	if err := copyFile(currentSnapshotPath, targetSnapshotPath); err != nil {
		return nil, err
	}
	return func() {
		_ = os.Remove(targetSnapshotPath)
	}, nil
}

func copyFile(srcPath, dstPath string) error {
	src, err := os.Open(srcPath)
	if err != nil {
		return fmt.Errorf("open stroma snapshot %s: %w", srcPath, err)
	}
	defer src.Close()

	info, err := src.Stat()
	if err != nil {
		return fmt.Errorf("stat stroma snapshot %s: %w", srcPath, err)
	}

	dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode())
	if err != nil {
		return fmt.Errorf("create stroma snapshot %s: %w", dstPath, err)
	}
	if _, err := io.Copy(dst, src); err != nil {
		_ = dst.Close()
		_ = os.Remove(dstPath)
		return fmt.Errorf("copy stroma snapshot to %s: %w", dstPath, err)
	}
	if err := dst.Close(); err != nil {
		_ = os.Remove(dstPath)
		return fmt.Errorf("close copied stroma snapshot %s: %w", dstPath, err)
	}
	return nil
}

func normalizeStromaUpdateError(snapshotPath string, err error) error {
	if err == nil {
		return nil
	}
	if ststore.IsMissingIndex(err) {
		return &UpdatePreconditionError{
			Reason: fmt.Sprintf("stroma snapshot %s does not exist", snapshotPath),
			Action: "run `pituitary index --rebuild`",
		}
	}
	if errors.Is(err, stindex.ErrUnsupportedSchemaVersion) {
		return &UpdatePreconditionError{
			Reason: strings.TrimSpace(err.Error()),
			Action: "run `pituitary index --rebuild`",
		}
	}
	message := strings.TrimSpace(err.Error())
	for _, marker := range []string{
		"embedder fingerprint mismatch",
		"embedder dimension mismatch",
		"update embedder is required when adding records",
	} {
		if strings.Contains(message, marker) {
			return &UpdatePreconditionError{
				Reason: message,
				Action: "run `pituitary index --rebuild`",
			}
		}
	}
	return err
}

// upsertMetadataContext inserts or replaces a metadata key-value pair.
func upsertMetadataContext(ctx context.Context, tx *sql.Tx, key, value string) error {
	if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)`, key, value); err != nil {
		return fmt.Errorf("upsert metadata %s: %w", key, err)
	}
	return nil
}

// runTransactionIntegrityChecks runs FK and integrity checks within the
// current transaction. These must run before commit so that failures trigger
// a rollback rather than leaving a corrupted live database.
func runTransactionIntegrityChecks(ctx context.Context, tx *sql.Tx) error {
	row := tx.QueryRowContext(ctx, `PRAGMA integrity_check`)
	var result string
	if err := row.Scan(&result); err != nil {
		return fmt.Errorf("run integrity_check: %w", err)
	}
	if strings.ToLower(result) != "ok" {
		return fmt.Errorf("integrity_check failed: %s", result)
	}

	rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
	if err != nil {
		return fmt.Errorf("run foreign_key_check: %w", err)
	}
	defer rows.Close()
	if rows.Next() {
		var table string
		var rowID int
		var parent string
		var fkid int
		if err := rows.Scan(&table, &rowID, &parent, &fkid); err != nil {
			return fmt.Errorf("scan foreign_key_check result: %w", err)
		}
		return fmt.Errorf("foreign_key_check failed for table %s row %d parent %s fk %d", table, rowID, parent, fkid)
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate foreign_key_check result: %w", err)
	}

	return nil
}

// Schema migration functions for V4-V7 were removed: they were never wired into
// the update eligibility chain, so every sub-V10 index already falls through to
// a full rebuild. Reintroduce them alongside a proper `applyPendingMigrations`
// driver when incremental upgrades from older schemas become a supported flow.
